Pārlūkot izejas kodu

Merge branch 'master' of github.com:porter-dev/porter into inference-fe

Feroze Mohideen 2 gadi atpakaļ
vecāks
revīzija
e92f3aac38
100 mainītis faili ar 1800 papildinājumiem un 6383 dzēšanām
  1. 23 19
      .github/workflows/install_script.yml
  2. 1 1
      api/server/handlers/addons/tailscale_services.go
  3. 4 9
      api/server/handlers/billing/create.go
  4. 62 14
      api/server/handlers/billing/ingest.go
  5. 7 17
      api/server/handlers/billing/invoices.go
  6. 23 16
      api/server/handlers/billing/list.go
  7. 39 69
      api/server/handlers/billing/plan.go
  8. 0 1
      api/server/handlers/cluster/install_agent.go
  9. 6 0
      api/server/handlers/datastore/update.go
  10. 69 0
      api/server/handlers/neon_integration/list.go
  11. 1 0
      api/server/handlers/oauth_callback/neon.go
  12. 22 0
      api/server/handlers/oauth_callback/upstash.go
  13. 40 12
      api/server/handlers/porter_app/create_app_template.go
  14. 126 0
      api/server/handlers/porter_app/templates_list.go
  15. 5 12
      api/server/handlers/project/create.go
  16. 12 7
      api/server/handlers/project/delete.go
  17. 7 9
      api/server/handlers/project/referrals.go
  18. 69 0
      api/server/handlers/upstash_integration/list.go
  19. 1 1
      api/server/router/cluster.go
  20. 29 0
      api/server/router/porter_app.go
  21. 56 28
      api/server/router/project.go
  22. 9 5
      api/server/shared/config/env/envconfs.go
  23. 14 14
      api/server/shared/config/loader/loader.go
  24. 0 231
      api/types/billing_metronome.go
  25. 0 15
      api/types/billing_stripe.go
  26. 89 0
      api/types/billing_usage.go
  27. 2 1
      api/types/project.go
  28. 20 0
      dashboard/src/assets/neon.svg
  29. 1 0
      dashboard/src/assets/plus-square.svg
  30. BIN
      dashboard/src/assets/quivr.png
  31. 15 0
      dashboard/src/assets/upstash.svg
  32. 0 15
      dashboard/src/components/Boilerplate.tsx
  33. 0 299
      dashboard/src/components/SOC2Checks.tsx
  34. 0 118
      dashboard/src/components/expanded-object/Header.tsx
  35. 1 0
      dashboard/src/components/porter/BlockSelect.tsx
  36. 0 34
      dashboard/src/components/porter/TemplateComponent.tsx
  37. 0 103
      dashboard/src/components/repo-selector/NewGHAction.tsx
  38. 0 1
      dashboard/src/lib/addons/deepgram.ts
  39. 60 2
      dashboard/src/lib/addons/index.ts
  40. 20 0
      dashboard/src/lib/addons/quivr.ts
  41. 56 2
      dashboard/src/lib/addons/template.ts
  42. 16 22
      dashboard/src/lib/billing/types.tsx
  43. 120 0
      dashboard/src/lib/clusters/constants.ts
  44. 16 0
      dashboard/src/lib/clusters/types.ts
  45. 34 1
      dashboard/src/lib/databases/types.ts
  46. 12 0
      dashboard/src/lib/environments/types.ts
  47. 53 0
      dashboard/src/lib/hooks/useAuthWindow.ts
  48. 0 449
      dashboard/src/lib/hooks/useClusterResourceLimits.ts
  49. 33 1
      dashboard/src/lib/hooks/useDatastore.ts
  50. 10 73
      dashboard/src/lib/hooks/useLago.ts
  51. 41 0
      dashboard/src/lib/hooks/useNeon.ts
  52. 0 35
      dashboard/src/lib/hooks/useResizeObserver.ts
  53. 87 0
      dashboard/src/lib/hooks/useTemplateEnvs.ts
  54. 41 0
      dashboard/src/lib/hooks/useUpstash.ts
  55. 6 0
      dashboard/src/lib/neon/types.ts
  56. 4 1
      dashboard/src/lib/porter-apps/build.ts
  57. 38 2
      dashboard/src/lib/porter-apps/index.ts
  58. 8 0
      dashboard/src/lib/upstash/types.ts
  59. 19 8
      dashboard/src/main/home/Home.tsx
  60. 0 301
      dashboard/src/main/home/WelcomeForm.tsx
  61. 3 0
      dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx
  62. 13 31
      dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx
  63. 2 0
      dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx
  64. 208 0
      dashboard/src/main/home/add-on-dashboard/quivr/QuivrForm.tsx
  65. 1 1
      dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx
  66. 21 2
      dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx
  67. 0 108
      dashboard/src/main/home/app-dashboard/app-view/tabs/preview-environments/PreviewEnvironmentSettings.tsx
  68. 12 2
      dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx
  69. 43 6
      dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx
  70. 3 2
      dashboard/src/main/home/app-dashboard/apps/Apps.tsx
  71. 6 1
      dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx
  72. 0 137
      dashboard/src/main/home/app-dashboard/build-settings/AdvancedBuildSettings.tsx
  73. 0 195
      dashboard/src/main/home/app-dashboard/build-settings/BuildSettingsTab.tsx
  74. 0 261
      dashboard/src/main/home/app-dashboard/build-settings/DetectDockerfileAndPorterYaml.tsx
  75. 0 195
      dashboard/src/main/home/app-dashboard/build-settings/SharedBuildSettings.tsx
  76. 0 158
      dashboard/src/main/home/app-dashboard/build-settings/buildpacks/AddCustomBuildpackComponent.tsx
  77. 0 168
      dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackCard.tsx
  78. 0 145
      dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackConfigurationModal.tsx
  79. 0 142
      dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackList.tsx
  80. 0 226
      dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackSettings.tsx
  81. 20 44
      dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx
  82. 0 128
      dashboard/src/main/home/app-dashboard/create-app/PorterYamlModal.tsx
  83. 0 224
      dashboard/src/main/home/app-dashboard/new-app-flow/GithubConnectModal.tsx
  84. 0 97
      dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx
  85. 0 144
      dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx
  86. 0 237
      dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx
  87. 0 189
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Old_GPUResources.tsx
  88. 7 2
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx
  89. 0 181
      dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/utils.ts
  90. 0 14
      dashboard/src/main/home/cluster-dashboard/ClusterPlaceholderContainer.tsx
  91. 0 408
      dashboard/src/main/home/cluster-dashboard/dashboard/Compliance.tsx
  92. 0 135
      dashboard/src/main/home/cluster-dashboard/dashboard/DonutChart.tsx
  93. 0 246
      dashboard/src/main/home/cluster-dashboard/dashboard/PorterAppDashboard.tsx
  94. 0 104
      dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentTypeStacks.tsx
  95. 0 115
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy/DeploySection.tsx
  96. 0 42
      dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy/EventTab.tsx
  97. 0 135
      dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx
  98. 0 52
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx
  99. 0 123
      dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx
  100. 34 35
      dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

+ 23 - 19
.github/workflows/install_script.yml

@@ -1,25 +1,29 @@
-"on":
+name: Deploy to install-script
+on:
   push:
     tags:
-      - production
-name: Deploy Install Script to Production
+    - production
 jobs:
   porter-deploy:
     runs-on: ubuntu-latest
     steps:
-      - name: Checkout code
-        uses: actions/checkout@v3
-      - name: Set Github tag
-        id: vars
-        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
-      - name: Update Porter App
-        timeout-minutes: 20
-        uses: porter-dev/porter-update-action@v0.1.0
-        with:
-          app: install-script
-          cluster: "9"
-          host: https://dashboard.internal-tools.porter.run
-          namespace: default
-          project: "5"
-          tag: ${{ steps.vars.outputs.sha_short }}
-          token: ${{ secrets.PORTER_TOKEN_5 }}
+    - name: Checkout code
+      uses: actions/checkout@v3
+    - name: Set Github tag
+      id: vars
+      run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
+    - name: Setup porter
+      uses: porter-dev/setup-porter@v0.1.0
+    - name: Deploy stack
+      timeout-minutes: 30
+      run: exec porter apply
+      env:
+        PORTER_CLUSTER: "9"
+        PORTER_DEPLOYMENT_TARGET_ID: b0fec389-99d5-4ca5-9012-002b410248b3
+        PORTER_HOST: https://dashboard.internal-tools.porter.run
+        PORTER_PR_NUMBER: ${{ github.event.number }}
+        PORTER_PROJECT: "5"
+        PORTER_REPO_NAME: ${{ github.event.repository.name }}
+        PORTER_STACK_NAME: install-script
+        PORTER_TAG: ${{ steps.vars.outputs.sha_short }}
+        PORTER_TOKEN: ${{ secrets.PORTER_STACK_5_9 }}

+ 1 - 1
api/server/handlers/addons/tailscale_services.go

@@ -79,7 +79,7 @@ func (c *TailscaleServicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	var services []TailscaleService
+	services := make([]TailscaleService, 0)
 	for _, svc := range svcList.Items {
 		var port int
 		if len(svc.Spec.Ports) > 0 {

+ 4 - 9
api/server/handlers/billing/create.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"net/http"
-	"time"
 
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
@@ -136,7 +135,7 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr
 		return telemetry.Error(ctx, span, err, "failed to get referral count by referrer id")
 	}
 
-	maxReferralRewards := c.Config().BillingManager.MetronomeClient.MaxReferralRewards
+	maxReferralRewards := c.Config().BillingManager.LagoClient.MaxReferralRewards
 	if referralCount >= maxReferralRewards {
 		return nil
 	}
@@ -147,13 +146,9 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr
 	}
 
 	if referral != nil && referral.Status != models.ReferralStatusCompleted {
-		// Metronome requires an expiration to be passed in, so we set it to 5 years which in
-		// practice will mean the credits will most likely run out before expiring
-		expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339)
-		reason := "Referral reward"
-		rewardAmount := c.Config().BillingManager.MetronomeClient.DefaultRewardAmountCents
-		paidAmount := c.Config().BillingManager.MetronomeClient.DefaultPaidAmountCents
-		err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, rewardAmount, paidAmount, expiresAt)
+		name := "Referral reward"
+		rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents
+		err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, referrerProject.EnableSandbox)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "failed to grand credits reward")
 		}

+ 62 - 14
api/server/handlers/billing/ingest.go

@@ -2,6 +2,9 @@
 package billing
 
 import (
+	"bytes"
+	"context"
+	"encoding/json"
 	"fmt"
 	"net/http"
 
@@ -36,22 +39,17 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-			telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
 	ingestEventsRequest := struct {
 		Events []types.BillingEvent `json:"billing_events"`
 	}{}
@@ -66,19 +64,69 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		telemetry.AttributeKV{Key: "usage-events-count", Value: len(ingestEventsRequest.Events)},
 	)
 
-	// For Porter Cloud events, we apend a prefix to avoid collisions before sending to Metronome
+	// For Porter Cloud events, we apend a prefix to avoid collisions before sending to Lago
 	if proj.EnableSandbox {
 		for i := range ingestEventsRequest.Events {
 			ingestEventsRequest.Events[i].CustomerID = fmt.Sprintf("porter-cloud-%s", ingestEventsRequest.Events[i].CustomerID)
 		}
 	}
 
-	err := c.Config().BillingManager.MetronomeClient.IngestEvents(ctx, ingestEventsRequest.Events)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomerActivePlan(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
+	)
+
+	err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, plan.ID, ingestEventsRequest.Events, proj.EnableSandbox)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error ingesting events")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	// Call the ingest health endpoint
+	err = c.postIngestHealthEndpoint(ctx, proj.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error calling ingest health endpoint")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	c.WriteResult(w, r, "")
 }
+
+func (c *IngestEventsHandler) postIngestHealthEndpoint(ctx context.Context, projectID uint) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "post-ingest-health-endpoint")
+	defer span.End()
+
+	// Call the ingest check webhook
+	webhookUrl := c.Config().ServerConf.IngestStatusWebhookUrl
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "ingest-status-webhook-url", Value: webhookUrl})
+
+	if webhookUrl == "" {
+		return nil
+	}
+
+	req := struct {
+		ProjectID uint `json:"project_id"`
+	}{
+		ProjectID: projectID,
+	}
+
+	reqBody, err := json.Marshal(req)
+	if err != nil {
+		return telemetry.Error(ctx, span, err, "error marshalling ingest status webhook request")
+	}
+
+	client := &http.Client{}
+	resp, err := client.Post(webhookUrl, "application/json", bytes.NewBuffer(reqBody))
+	if err != nil || resp.StatusCode != http.StatusOK {
+		return telemetry.Error(ctx, span, err, "error sending ingest status webhook request")
+	}
+	return nil
+}

+ 7 - 17
api/server/handlers/billing/invoices.go

@@ -1,7 +1,6 @@
 package billing
 
 import (
-	"fmt"
 	"net/http"
 
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -25,7 +24,7 @@ func NewListCustomerInvoicesHandler(
 	writer shared.ResultWriter,
 ) *ListCustomerInvoicesHandler {
 	return &ListCustomerInvoicesHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
 	}
 }
 
@@ -36,31 +35,22 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "billing-config-exists", Value: c.Config().BillingManager.StripeConfigLoaded},
-		telemetry.AttributeKV{Key: "billing-enabled", Value: proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient)},
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 		telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox},
 	)
 
-	if !c.Config().BillingManager.StripeConfigLoaded || !proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 		return
 	}
 
-	req := &types.ListCustomerInvoicesRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, req); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding list customer usage request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	invoices, err := c.Config().BillingManager.StripeClient.ListCustomerInvoices(ctx, proj.BillingID, req.Status)
+	invoices, err := c.Config().BillingManager.LagoClient.ListCustomerFinalizedInvoices(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, fmt.Sprintf("error listing invoices for customer %s", proj.BillingID))
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		err = telemetry.Error(ctx, span, err, "error listing invoices")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// Write the response to the frontend
 	c.WriteResult(w, r, invoices)
 }

+ 23 - 16
api/server/handlers/billing/list.go

@@ -5,7 +5,6 @@ import (
 	"fmt"
 	"net/http"
 
-	"github.com/google/uuid"
 	"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"
@@ -100,10 +99,9 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro
 
 	telemetry.WithAttributes(span,
 		telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
 	)
 
-	if proj.BillingID == "" || proj.UsageID == uuid.Nil {
+	if proj.BillingID == "" {
 		adminUser, err := c.getAdminUser(ctx, proj.ID)
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "error getting admin user")
@@ -119,11 +117,19 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro
 		if err != nil {
 			return telemetry.Error(ctx, span, err, "error ensuring Stripe customer exists")
 		}
+	}
+
+	lagoCustomerExists := false
+	if !lagoCustomerExists {
+		adminUser, err := c.getAdminUser(ctx, proj.ID)
+		if err != nil {
+			return telemetry.Error(ctx, span, err, "error getting admin user")
+		}
 
 		// Create usage customer for project and set the usage ID if it doesn't exist
-		err = c.ensureMetronomeCustomerExists(ctx, adminUser.Email, proj)
+		err = c.ensureLagoCustomerExists(ctx, adminUser.Email, proj)
 		if err != nil {
-			return telemetry.Error(ctx, span, err, "error ensuring Metronome customer exists")
+			return telemetry.Error(ctx, span, err, "error ensuring Lago customer exists")
 		}
 	}
 
@@ -189,30 +195,31 @@ func (c *CheckPaymentEnabledHandler) ensureStripeCustomerExists(ctx context.Cont
 	return nil
 }
 
-func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
-	ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists")
+func (c *CheckPaymentEnabledHandler) ensureLagoCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) {
+	ctx, span := telemetry.NewSpan(ctx, "ensure-lago-customer-exists")
 	defer span.End()
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID != uuid.Nil {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		return nil
 	}
 
-	customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
+	// Check if the customer already exists
+	exists, err := c.Config().BillingManager.LagoClient.CheckIfCustomerExists(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "error creating Metronome customer")
+		return telemetry.Error(ctx, span, err, "error while checking if customer exists")
 	}
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-		telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
+		telemetry.AttributeKV{Key: "customer-exists", Value: exists},
 	)
 
-	proj.UsageID = customerID
-	proj.UsagePlanID = customerPlanID
+	if exists {
+		return nil
+	}
 
-	_, err = c.Repo().Project().UpdateProject(proj)
+	err = c.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 	if err != nil {
-		return telemetry.Error(ctx, span, err, "error updating project")
+		return telemetry.Error(ctx, span, err, "error creating Lago customer")
 	}
 
 	return nil

+ 39 - 69
api/server/handlers/billing/plan.go

@@ -33,28 +33,43 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
+
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
+		return
+	}
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomerActivePlan(ctx, proj.ID, proj.EnableSandbox)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
 	)
 
-	plan, err := c.Config().BillingManager.MetronomeClient.ListCustomerPlan(ctx, proj.UsageID)
+	endingBefore, err := c.Config().BillingManager.LagoClient.CheckCustomerCouponExpiration(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing plans")
+		err := telemetry.Error(ctx, span, err, "error listing active coupons")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
+	// If the customer has a coupon, use its end date instead of the trial end date
+	if endingBefore != "" {
+		plan.TrialInfo.EndingBefore = endingBefore
+	}
+
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "trial-ending-at", Value: plan.TrialInfo.EndingBefore},
+	)
+
 	c.WriteResult(w, r, plan)
 }
 
@@ -79,28 +94,23 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
+		c.WriteResult(w, r, "")
 		return
 	}
 
-	credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID)
+	credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
 		err := telemetry.Error(ctx, span, err, "error listing credits")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: true},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-	)
-
 	c.WriteResult(w, r, credits)
 }
 
@@ -127,12 +137,11 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
 	)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) {
 		c.WriteResult(w, r, "")
 		return
 	}
@@ -145,59 +154,20 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod)
+	plan, err := c.Config().BillingManager.LagoClient.GetCustomerActivePlan(ctx, proj.ID, proj.EnableSandbox)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing customer usage")
+		err := telemetry.Error(ctx, span, err, "error getting active subscription")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
-	c.WriteResult(w, r, usage)
-}
-
-// ListCustomerCostsHandler returns customer usage aggregations like CPU and RAM hours.
-type ListCustomerCostsHandler struct {
-	handlers.PorterHandlerReadWriter
-}
-
-// NewListCustomerCostsHandler returns a new ListCustomerCostsHandler
-func NewListCustomerCostsHandler(
-	config *config.Config,
-	decoderValidator shared.RequestDecoderValidator,
-	writer shared.ResultWriter,
-) *ListCustomerCostsHandler {
-	return &ListCustomerCostsHandler{
-		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
-	}
-}
-
-func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-costs")
-	defer span.End()
-
-	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	telemetry.WithAttributes(span,
-		telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-		telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
+		telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID},
 	)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) {
-		c.WriteResult(w, r, "")
-		return
-	}
-
-	req := &types.ListCustomerCostsRequest{}
-
-	if ok := c.DecodeAndValidate(w, r, req); !ok {
-		err := telemetry.Error(ctx, span, nil, "error decoding list customer costs request")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
-		return
-	}
-
-	usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit)
+	usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod, req.PreviousPeriods)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "error listing customer costs")
+		err := telemetry.Error(ctx, span, err, "error listing customer usage")
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}

+ 0 - 1
api/server/handlers/cluster/install_agent.go

@@ -112,7 +112,6 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 			"clusterID":     fmt.Sprintf("%d", cluster.ID),
 			"projectID":     fmt.Sprintf("%d", proj.ID),
 			"prometheusURL": c.Config().ServerConf.PrometheusUrl,
-			"metronomeKey":  c.Config().ServerConf.MetronomeAPIKey,
 		},
 		"loki": map[string]interface{}{},
 	}

+ 6 - 0
api/server/handlers/datastore/update.go

@@ -187,6 +187,12 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				MasterUserPasswordLiteral: pointer.String(datastoreValues.Config.MasterUserPassword),
 			},
 		}
+	case "NEON":
+		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_NEON
+		datastoreProto.KindValues = &porterv1.ManagedDatastore_NeonKind{}
+	case "UPSTASH":
+		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_UPSTASH
+		datastoreProto.KindValues = &porterv1.ManagedDatastore_UpstashKind{}
 	default:
 		err = telemetry.Error(ctx, span, nil, "invalid datastore type")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))

+ 69 - 0
api/server/handlers/neon_integration/list.go

@@ -0,0 +1,69 @@
+package neon_integration
+
+import (
+	"net/http"
+	"time"
+
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListNeonIntegrationsHandler is a struct for listing all neon integrations for a given project
+type ListNeonIntegrationsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListNeonIntegrationsHandler constructs a ListNeonIntegrationsHandler
+func NewListNeonIntegrationsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListNeonIntegrationsHandler {
+	return &ListNeonIntegrationsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// NeonIntegration describes a neon integration
+type NeonIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+}
+
+// ListNeonIntegrationsResponse describes the list neon integrations response body
+type ListNeonIntegrationsResponse struct {
+	// Integrations is a list of neon integrations
+	Integrations []NeonIntegration `json:"integrations"`
+}
+
+// ServeHTTP returns a list of neon integrations associated with the specified project
+func (h *ListNeonIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-neon-integrations")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	resp := ListNeonIntegrationsResponse{}
+	integrationList := make([]NeonIntegration, 0)
+
+	integrations, err := h.Repo().NeonIntegration().Integrations(ctx, project.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting datastores")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, int := range integrations {
+		integrationList = append(integrationList, NeonIntegration{
+			CreatedAt: int.CreatedAt,
+		})
+	}
+
+	resp.Integrations = integrationList
+
+	h.WriteResult(w, r, resp)
+}

+ 1 - 0
api/server/handlers/oauth_callback/neon.go

@@ -93,6 +93,7 @@ func (p *OAuthCallbackNeonHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 
 	oauthInt := integrations.NeonIntegration{
 		SharedOAuthModel: integrations.SharedOAuthModel{
+			ClientID:     []byte(p.Config().NeonConf.ClientID),
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
 			Expiry:       token.Expiry,

+ 22 - 0
api/server/handlers/oauth_callback/upstash.go

@@ -10,6 +10,7 @@ import (
 	"net/url"
 	"time"
 
+	"github.com/golang-jwt/jwt"
 	"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"
@@ -100,6 +101,25 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		return
 	}
 
+	t, _, err := new(jwt.Parser).ParseUnverified(token.AccessToken, jwt.MapClaims{}) // safe to use because we validated the token above
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error parsing token")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	var email string
+	if claims, ok := t.Claims.(jwt.MapClaims); ok {
+		if emailVal, ok := claims["https://user.io/email"].(string); ok {
+			email = emailVal
+		}
+	}
+	if email == "" {
+		err = telemetry.Error(ctx, span, nil, "email not found in token")
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	// make an http call to https://api.upstash.com/apikey with authorization: bearer <access_token>
 	// to get the api key
 	apiKey, err := fetchUpstashApiKey(ctx, token.AccessToken)
@@ -111,12 +131,14 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	oauthInt := integrations.UpstashIntegration{
 		SharedOAuthModel: integrations.SharedOAuthModel{
+			ClientID:     []byte(p.Config().UpstashConf.ClientID),
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
 			Expiry:       token.Expiry,
 		},
 		ProjectID:       projID,
 		DeveloperApiKey: []byte(apiKey),
+		UpstashEmail:    email,
 	}
 
 	_, err = p.Repo().UpstashIntegration().Insert(ctx, oauthInt)

+ 40 - 12
api/server/handlers/porter_app/create_app_template.go

@@ -56,6 +56,7 @@ type CreateAppTemplateRequest struct {
 	Secrets                map[string]string        `json:"secrets"`
 	BaseDeploymentTargetID string                   `json:"base_deployment_target_id"`
 	Addons                 []Base64AddonWithEnvVars `json:"addons"`
+	GitOverrides           GitSource                `json:"git_overrides"`
 }
 
 // CreateAppTemplateResponse is the response object for the /app-template POST endpoint
@@ -173,22 +174,49 @@ func (c *CreateAppTemplateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	err = porter_app.CreateAppWebhook(ctx, porter_app.CreateAppWebhookInput{
-		PorterAppName:           appName,
-		ProjectID:               project.ID,
-		ClusterID:               cluster.ID,
-		GithubAppSecret:         c.Config().ServerConf.GithubAppSecret,
-		GithubAppID:             c.Config().ServerConf.GithubAppID,
-		GithubWebhookSecret:     c.Config().ServerConf.GithubIncomingWebhookSecret,
-		ServerURL:               c.Config().ServerConf.ServerURL,
-		PorterAppRepository:     c.Repo().PorterApp(),
-		GithubWebhookRepository: c.Repo().GithubWebhook(),
-	})
+	porterApp, err := c.Repo().PorterApp().ReadPorterAppByName(cluster.ID, appName)
 	if err != nil {
-		err := telemetry.Error(ctx, span, err, "unable to set repo webhook")
+		err = telemetry.Error(ctx, span, err, "could not read porter app by name")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
+	if porterApp.ID == 0 {
+		err = telemetry.Error(ctx, span, nil, "porter app not found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+
+	gitSource := GitSource{
+		GitBranch:   porterApp.GitBranch,
+		GitRepoName: porterApp.RepoName,
+		GitRepoID:   porterApp.GitRepoID,
+	}
+	if request.GitOverrides.GitRepoName != "" {
+		gitSource = request.GitOverrides
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "git-repo-name", Value: gitSource.GitRepoName})
+
+	if gitSource.GitRepoName != "" {
+		err = porter_app.CreateAppWebhook(ctx, porter_app.CreateAppWebhookInput{
+			ProjectID:   project.ID,
+			ClusterID:   cluster.ID,
+			PorterAppID: porterApp.ID,
+			GitSource: porter_app.GitSource{
+				GitRepoName: gitSource.GitRepoName,
+				GitRepoID:   gitSource.GitRepoID,
+			},
+			GithubAppSecret:         c.Config().ServerConf.GithubAppSecret,
+			GithubAppID:             c.Config().ServerConf.GithubAppID,
+			GithubWebhookSecret:     c.Config().ServerConf.GithubIncomingWebhookSecret,
+			ServerURL:               c.Config().ServerConf.ServerURL,
+			GithubWebhookRepository: c.Repo().GithubWebhook(),
+		})
+		if err != nil {
+			err := telemetry.Error(ctx, span, err, "unable to set repo webhook")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+	}
 
 	res := &CreateAppTemplateResponse{}
 

+ 126 - 0
api/server/handlers/porter_app/templates_list.go

@@ -0,0 +1,126 @@
+package porter_app
+
+import (
+	"context"
+	"encoding/base64"
+	"net/http"
+
+	"connectrpc.com/connect"
+	"github.com/porter-dev/api-contracts/generated/go/helpers"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+	"google.golang.org/protobuf/reflect/protoreflect"
+)
+
+// ListEnvironmentTemplatesHandler handles requests to the /apps/templates endpoint
+type ListEnvironmentTemplatesHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListEnvironmentTemplatesHandler returns a new ListEnvironmentTemplatesHandler
+func NewListEnvironmentTemplatesHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListEnvironmentTemplatesHandler {
+	return &ListEnvironmentTemplatesHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// Environment is a partially encoded environment object
+type Environment struct {
+	// Name is the name of the environment
+	Name string `json:"name"`
+	// Base64 Apps is a list of apps that are deployed in the environment
+	Base64Apps []string `json:"base64_apps,omitempty"`
+	// Base64Addons is a list of encoded addons that are deployed in the environment
+	Base64Addons []string `json:"base64_addons,omitempty"`
+}
+
+// ListEnvironmentTemplatesResponse represents the response from the /apps/templates endpoint
+type ListEnvironmentTemplatesResponse struct {
+	EnvironmentTemplates []Environment `json:"environment_templates,omitempty"`
+}
+
+// ServeHTTP lists all environment templates
+func (c *ListEnvironmentTemplatesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-environment-templates")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+	cluster, _ := ctx.Value(types.ClusterScope).(*models.Cluster)
+
+	listTemplatesReq := connect.NewRequest(&porterv1.ListTemplatesRequest{
+		ProjectId: int64(project.ID),
+		ClusterId: int64(cluster.ID),
+	})
+
+	listTemplatesResp, err := c.Config().ClusterControlPlaneClient.ListTemplates(ctx, listTemplatesReq)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "error listing templates")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+	if listTemplatesResp == nil || listTemplatesResp.Msg == nil {
+		err = telemetry.Error(ctx, span, nil, "list templates response is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	// get all apps for each environment
+	var envTemplates []Environment
+
+	for _, env := range listTemplatesResp.Msg.EnvironmentTemplates {
+		var encodedApps []string
+		for _, app := range env.Apps {
+			encoded, err := base64EncodeContractObject(ctx, app)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding app")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedApps = append(encodedApps, encoded)
+		}
+
+		var encodedAddons []string
+		for _, addon := range env.Addons {
+			encoded, err := base64EncodeContractObject(ctx, addon)
+			if err != nil {
+				err = telemetry.Error(ctx, span, err, "error encoding addon")
+				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+				return
+			}
+
+			encodedAddons = append(encodedAddons, encoded)
+		}
+
+		envTemplates = append(envTemplates, Environment{
+			Name:         env.Name,
+			Base64Apps:   encodedApps,
+			Base64Addons: encodedAddons,
+		})
+	}
+
+	res := ListEnvironmentTemplatesResponse{
+		EnvironmentTemplates: envTemplates,
+	}
+
+	c.WriteResult(w, r, res)
+}
+
+func base64EncodeContractObject(ctx context.Context, pc protoreflect.ProtoMessage) (string, error) {
+	by, err := helpers.MarshalContractObject(ctx, pc)
+	if err != nil {
+		return "", err
+	}
+
+	return base64.StdEncoding.EncodeToString(by), nil
+}

+ 5 - 12
api/server/handlers/project/create.go

@@ -3,7 +3,6 @@ package project
 import (
 	"net/http"
 
-	"github.com/google/uuid"
 	"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"
@@ -100,23 +99,17 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		)
 	}
 
-	// Create Metronome customer and add to starter plan
-	if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
-		customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
+	// Create Lago customer and add to starter plan
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
+		err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox)
 		if err != nil {
-			err = telemetry.Error(ctx, span, err, "error creating Metronome customer")
+			err = telemetry.Error(ctx, span, err, "error creating usage customer")
 			p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
 		}
-		proj.UsageID = customerID
-		proj.UsagePlanID = customerPlanID
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-			telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
-		)
 	}
 
-	if proj.BillingID != "" || proj.UsageID != uuid.Nil {
+	if proj.BillingID != "" {
 		_, err = p.Repo().Project().UpdateProject(proj)
 		if err != nil {
 			err := telemetry.Error(ctx, span, err, "error updating project")

+ 12 - 7
api/server/handlers/project/delete.go

@@ -92,19 +92,24 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
-	if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) {
-		err = p.Config().BillingManager.MetronomeClient.EndCustomerPlan(ctx, proj.UsageID, proj.UsagePlanID)
+	if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) {
+		err = p.Config().BillingManager.LagoClient.DeleteCustomer(ctx, proj.ID, proj.EnableSandbox)
 		if err != nil {
 			e := "error ending billing plan"
 			err = telemetry.Error(ctx, span, err, e)
 			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 			return
 		}
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "project-id", Value: proj.ID},
-			telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID},
-			telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID},
-		)
+	}
+
+	if p.Config().BillingManager.StripeConfigLoaded && proj.GetFeatureFlag(models.BillingEnabled, p.Config().LaunchDarklyClient) {
+		err = p.Config().BillingManager.StripeClient.DeleteCustomer(ctx, proj.BillingID)
+		if err != nil {
+			e := "error deleting stripe customer"
+			err = telemetry.Error(ctx, span, err, e)
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
 	}
 
 	deletedProject, err := p.Repo().Project().DeleteProject(proj)

+ 7 - 9
api/server/handlers/project/referrals.go

@@ -3,7 +3,6 @@ package project
 import (
 	"net/http"
 
-	"github.com/google/uuid"
 	"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"
@@ -34,14 +33,13 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 	proj, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
-	if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) ||
-		proj.UsageID == uuid.Nil || !proj.EnableSandbox {
-		c.WriteResult(w, r, "")
+	telemetry.WithAttributes(span,
+		telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded},
+		telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)},
+	)
 
-		telemetry.WithAttributes(span,
-			telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded},
-			telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)},
-		)
+	if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox {
+		c.WriteResult(w, r, "")
 		return
 	}
 
@@ -74,7 +72,7 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h
 	}{
 		Code:              proj.ReferralCode,
 		ReferralCount:     referralCount,
-		MaxAllowedRewards: c.Config().BillingManager.MetronomeClient.MaxReferralRewards,
+		MaxAllowedRewards: c.Config().BillingManager.LagoClient.MaxReferralRewards,
 	}
 
 	c.WriteResult(w, r, referralCodeResponse)

+ 69 - 0
api/server/handlers/upstash_integration/list.go

@@ -0,0 +1,69 @@
+package upstash_integration
+
+import (
+	"net/http"
+	"time"
+
+	"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"
+	"github.com/porter-dev/porter/internal/telemetry"
+)
+
+// ListUpstashIntegrationsHandler is a struct for listing all upstash integrations for a given project
+type ListUpstashIntegrationsHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+// NewListUpstashIntegrationsHandler constructs a ListUpstashIntegrationsHandler
+func NewListUpstashIntegrationsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListUpstashIntegrationsHandler {
+	return &ListUpstashIntegrationsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+// UpstashIntegration describes a upstash integration
+type UpstashIntegration struct {
+	CreatedAt time.Time `json:"created_at"`
+}
+
+// ListUpstashIntegrationsResponse describes the list upstash integrations response body
+type ListUpstashIntegrationsResponse struct {
+	// Integrations is a list of upstash integrations
+	Integrations []UpstashIntegration `json:"integrations"`
+}
+
+// ServeHTTP returns a list of upstash integrations associated with the specified project
+func (h *ListUpstashIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-list-upstash-integrations")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	resp := ListUpstashIntegrationsResponse{}
+	integrationList := make([]UpstashIntegration, 0)
+
+	integrations, err := h.Repo().UpstashIntegration().Integrations(ctx, project.ID)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting datastores")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	for _, int := range integrations {
+		integrationList = append(integrationList, UpstashIntegration{
+			CreatedAt: int.CreatedAt,
+		})
+	}
+
+	resp.Integrations = integrationList
+
+	h.WriteResult(w, r, resp)
+}

+ 1 - 1
api/server/router/cluster.go

@@ -979,7 +979,7 @@ func getClusterRoutes(
 		// GET /api/projects/{project_id}/clusters/{cluster_id}/kubeconfig -> cluster.NewGetTemporaryKubeconfigHandler
 		getTemporaryKubeconfigEndpoint := factory.NewAPIEndpoint(
 			&types.APIRequestMetadata{
-				Verb:   types.APIVerbUpdate, // we do not want users with no-write access to be able to use this
+				Verb:   types.APIVerbGet,
 				Method: types.HTTPVerbGet,
 				Path: &types.Path{
 					Parent:       basePath,

+ 29 - 0
api/server/router/porter_app.go

@@ -1589,6 +1589,35 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/templates -> porter_app.NewListEnvironmentTemplatesHandler
+	listEnvironmentTemplatesEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/templates", relPathV2),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listEnvironmentTemplatesHandler := porter_app.NewListEnvironmentTemplatesHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: listEnvironmentTemplatesEndpoint,
+		Handler:  listEnvironmentTemplatesHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/{porter_app_name}/templates -> porter_app.NewGetAppTemplateHandler
 	getAppTemplateEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 56 - 28
api/server/router/project.go

@@ -4,6 +4,8 @@ import (
 	"fmt"
 
 	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
+	"github.com/porter-dev/porter/api/server/handlers/neon_integration"
+	"github.com/porter-dev/porter/api/server/handlers/upstash_integration"
 
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 
@@ -452,34 +454,6 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/billing/costs -> project.NewListCustomerCostsHandler
-	listCustomerCostsEndpoint := factory.NewAPIEndpoint(
-		&types.APIRequestMetadata{
-			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
-			Path: &types.Path{
-				Parent:       basePath,
-				RelativePath: relPath + "/billing/costs",
-			},
-			Scopes: []types.PermissionScope{
-				types.UserScope,
-				types.ProjectScope,
-			},
-		},
-	)
-
-	listCustomerCostsHandler := billing.NewListCustomerCostsHandler(
-		config,
-		factory.GetDecoderValidator(),
-		factory.GetResultWriter(),
-	)
-
-	routes = append(routes, &router.Route{
-		Endpoint: listCustomerCostsEndpoint,
-		Handler:  listCustomerCostsHandler,
-		Router:   r,
-	})
-
 	// GET /api/projects/{project_id}/billing/invoices -> project.NewListCustomerInvoicesHandler
 	listCustomerInvoicesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
@@ -2021,5 +1995,59 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/neon-integrations -> apiContract.NewListNeonIntegrationsHandler
+	listNeonIntegrationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/neon-integrations",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listNeonIntegrationsHandler := neon_integration.NewListNeonIntegrationsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+	routes = append(routes, &router.Route{
+		Endpoint: listNeonIntegrationsEndpoint,
+		Handler:  listNeonIntegrationsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/upstash-integrations -> apiContract.NewListUpstashIntegrationsHandler
+	listUpstashIntegrationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/upstash-integrations",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listUpstashIntegrationsHandler := upstash_integration.NewListUpstashIntegrationsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+	routes = append(routes, &router.Route{
+		Endpoint: listUpstashIntegrationsEndpoint,
+		Handler:  listUpstashIntegrationsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 9 - 5
api/server/shared/config/env/envconfs.go

@@ -69,11 +69,15 @@ type ServerConf struct {
 	SendgridDeleteProjectTemplateID    string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"`
 	SendgridSenderEmail                string `env:"SENDGRID_SENDER_EMAIL"`
 
-	StripeSecretKey      string `env:"STRIPE_SECRET_KEY"`
-	StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"`
-	MetronomeAPIKey      string `env:"METRONOME_API_KEY"`
-	PorterCloudPlanID    string `env:"PORTER_CLOUD_PLAN_ID"`
-	PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"`
+	StripeSecretKey        string `env:"STRIPE_SECRET_KEY"`
+	StripePublishableKey   string `env:"STRIPE_PUBLISHABLE_KEY"`
+	LagoAPIKey             string `env:"LAGO_API_KEY"`
+	PorterCloudPlanCode    string `env:"PORTER_CLOUD_PLAN_CODE"`
+	PorterStandardPlanCode string `env:"PORTER_STANDARD_PLAN_CODE"`
+	PorterTrialCode        string `env:"PORTER_TRIAL_CODE"`
+
+	// The URL of the webhook to verify ingesting events works
+	IngestStatusWebhookUrl string `env:"INGEST_STATUS_WEBHOOK_URL"`
 
 	// This endpoint will be passed to the porter-agent so that
 	// the billing manager can query Prometheus.

+ 14 - 14
api/server/shared/config/loader/loader.go

@@ -358,10 +358,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 	}
 
 	var (
-		stripeClient     billing.StripeClient
-		stripeEnabled    bool
-		metronomeClient  billing.MetronomeClient
-		metronomeEnabled bool
+		stripeClient  billing.StripeClient
+		stripeEnabled bool
+		lagoClient    billing.LagoClient
+		lagoEnabled   bool
 	)
 	if sc.StripeSecretKey != "" {
 		stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey)
@@ -371,23 +371,23 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 		res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled")
 	}
 
-	if sc.MetronomeAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" {
-		metronomeClient, err = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID)
+	if sc.LagoAPIKey != "" && sc.PorterCloudPlanCode != "" && sc.PorterStandardPlanCode != "" && sc.PorterTrialCode != "" {
+		lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanCode, InstanceEnvConf.ServerConf.PorterStandardPlanCode, InstanceEnvConf.ServerConf.PorterTrialCode)
 		if err != nil {
-			return nil, fmt.Errorf("unable to create metronome client: %w", err)
+			return nil, fmt.Errorf("unable to create Lago client: %w", err)
 		}
-		metronomeEnabled = true
-		res.Logger.Info().Msg("Metronome configuration loaded")
+		lagoEnabled = true
+		res.Logger.Info().Msg("Lago configuration loaded")
 	} else {
-		res.Logger.Info().Msg("METRONOME_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled")
+		res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_CODE, PORTER_STANDARD_PLAN_CODE and PORTER_TRIAL_CODE must be set, all Lago functionality will be disabled")
 	}
 
 	res.Logger.Info().Msg("Creating billing manager")
 	res.BillingManager = billing.Manager{
-		StripeClient:          stripeClient,
-		StripeConfigLoaded:    stripeEnabled,
-		MetronomeClient:       metronomeClient,
-		MetronomeConfigLoaded: metronomeEnabled,
+		StripeClient:       stripeClient,
+		StripeConfigLoaded: stripeEnabled,
+		LagoClient:         lagoClient,
+		LagoConfigLoaded:   lagoEnabled,
 	}
 	res.Logger.Info().Msg("Created billing manager")
 

+ 0 - 231
api/types/billing_metronome.go

@@ -1,231 +0,0 @@
-package types
-
-import "github.com/google/uuid"
-
-// Customer represents a customer in Metronome
-type Customer struct {
-	ID   uuid.UUID `json:"id"`
-	Name string    `json:"name"`
-	// Aliases are alternative ids that can be used to refer to this customer in usage events
-	Aliases       []string          `json:"ingest_aliases"`
-	BillingConfig BillingConfig     `json:"billing_config,omitempty"`
-	CustomFields  map[string]string `json:"custom_fields,omitempty"`
-}
-
-// CustomerArchiveRequest will archive the customer in Metronome.
-type CustomerArchiveRequest struct {
-	CustomerID uuid.UUID `json:"id"`
-}
-
-// BillingConfig is the configuration for the billing provider (Stripe, etc.)
-type BillingConfig struct {
-	// BillingProviderType is the name of the billing provider (e.g. )
-	BillingProviderType       string `json:"billing_provider_type"`
-	BillingProviderCustomerID string `json:"billing_provider_customer_id"`
-	// StripeCollectionMethod defines if invoices are charged automatically or sent to customers
-	StripeCollectionMethod string `json:"stripe_collection_method"`
-}
-
-// AddCustomerPlanRequest represents a request to add a customer plan with specific details.
-type AddCustomerPlanRequest struct {
-	PlanID uuid.UUID `json:"plan_id"`
-	// StartingOn is a RFC3339 timestamp for when the plan becomes active for this customer. Must be at 0:00 UTC (midnight)
-	StartingOnUTC string `json:"starting_on"`
-	// EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight)
-	EndingBeforeUTC string `json:"ending_before,omitempty"`
-	// NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due
-	NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"`
-	// Trial is the trial period for the plan
-	Trial *TrialSpec `json:"trial_spec,omitempty"`
-}
-
-// TrialSpec is the trial period for the plan
-type TrialSpec struct {
-	LengthInDays int64 `json:"length_in_days"`
-}
-
-// EndCustomerPlanRequest represents a request to end the plan for a specific customer.
-type EndCustomerPlanRequest struct {
-	// EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight).
-	EndingBeforeUTC string `json:"ending_before,omitempty"`
-	// VoidInvoices determines if Metronome invoices are voided. If set to true, the plan end date can be before the last finalized invoice date.
-	// and any invoices generated after the plan end date will be voided.
-	VoidInvoices bool `json:"void_invoices"`
-	// VoidStripeInvoices determines if Stripe invoices are void (if VoidInvoices is set to true). Drafts will be deleted.
-	VoidStripeInvoices bool `json:"void_stripe_invoices"`
-}
-
-// CreateCreditsGrantRequest is the request to create a credit grant for a customer
-type CreateCreditsGrantRequest struct {
-	// CustomerID is the id of the customer
-	CustomerID    uuid.UUID     `json:"customer_id"`
-	UniquenessKey string        `json:"uniqueness_key"`
-	GrantAmount   GrantAmountID `json:"grant_amount"`
-	PaidAmount    PaidAmount    `json:"paid_amount"`
-	Name          string        `json:"name"`
-	ExpiresAt     string        `json:"expires_at"`
-	Priority      int           `json:"priority"`
-	Reason        string        `json:"reason"`
-}
-
-// ListCreditGrantsRequest is the request to list a user's credit grants. Note that only one of
-// CreditTypeIDs, CustomerIDs, or CreditGrantIDs must be specified.
-type ListCreditGrantsRequest struct {
-	CreditTypeIDs  []uuid.UUID `json:"credit_type_ids,omitempty"`
-	CustomerIDs    []uuid.UUID `json:"customer_ids,omitempty"`
-	CreditGrantIDs []uuid.UUID `json:"credit_grant_ids,omitempty"`
-	// NotExpiringBefore will return grants that expire at or after this RFC 3339 timestamp.
-	NotExpiringBefore string `json:"not_expiring_before,omitempty"`
-	// EffectiveBefore will return grants that are effective before this RFC 3339 timestamp (exclusive).
-	EffectiveBefore string `json:"effective_before,omitempty"`
-}
-
-// ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
-type ListCreditGrantsResponse struct {
-	RemainingCredits float64 `json:"remaining_credits"`
-	GrantedCredits   float64 `json:"granted_credits"`
-}
-
-// ListCustomerUsageRequest is the request to list usage for a customer
-type ListCustomerUsageRequest struct {
-	CustomerID       uuid.UUID `json:"customer_id"`
-	BillableMetricID uuid.UUID `json:"billable_metric_id"`
-	WindowSize       string    `json:"window_size"`
-	StartingOn       string    `json:"starting_on,omitempty"`
-	EndingBefore     string    `json:"ending_before,omitempty"`
-	CurrentPeriod    bool      `json:"current_period,omitempty"`
-}
-
-// Usage is the aggregated usage for a customer
-type Usage struct {
-	MetricName   string                `json:"metric_name"`
-	UsageMetrics []CustomerUsageMetric `json:"usage_metrics"`
-}
-
-// CustomerUsageMetric is a metric representing usage for a customer
-type CustomerUsageMetric struct {
-	StartingOn   string  `json:"starting_on"`
-	EndingBefore string  `json:"ending_before"`
-	Value        float64 `json:"value"`
-}
-
-// BillableMetric is defined in Metronome and represents the events that will
-// be ingested
-type BillableMetric struct {
-	ID   uuid.UUID `json:"id"`
-	Name string    `json:"name"`
-}
-
-// ListCustomerCostsRequest is the request to list costs for a customer
-type ListCustomerCostsRequest struct {
-	StartingOn   string `schema:"starting_on"`
-	EndingBefore string `schema:"ending_before"`
-	Limit        int    `schema:"limit"`
-}
-
-// Cost is the cost for a customer in a specific time range
-type Cost struct {
-	StartTimestamp string                    `json:"start_timestamp"`
-	EndTimestamp   string                    `json:"end_timestamp"`
-	CreditTypes    map[string]CreditTypeCost `json:"credit_types"`
-}
-
-// CreditTypeCost is the cost for a specific credit type (e.g. CPU hours)
-type CreditTypeCost struct {
-	Name              string                  `json:"name"`
-	Cost              float64                 `json:"cost"`
-	LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"`
-}
-
-// LineItemBreakdownCost is the cost breakdown by line item
-type LineItemBreakdownCost struct {
-	Name string  `json:"name"`
-	Cost float64 `json:"cost"`
-}
-
-// FormattedCost is the cost for a customer in a specific time range, flattened from the Metronome response
-type FormattedCost struct {
-	StartTimestamp string  `json:"start_timestamp"`
-	EndTimestamp   string  `json:"end_timestamp"`
-	Cost           float64 `json:"cost"`
-}
-
-type Plan struct {
-	ID                  uuid.UUID `json:"id"`
-	PlanID              uuid.UUID `json:"plan_id"`
-	PlanName            string    `json:"plan_name"`
-	PlanDescription     string    `json:"plan_description"`
-	StartingOn          string    `json:"starting_on"`
-	EndingBefore        string    `json:"ending_before"`
-	NetPaymentTermsDays int       `json:"net_payment_terms_days"`
-	TrialInfo           Trial     `json:"trial_info,omitempty"`
-}
-
-// Trial contains the information for a trial period
-type Trial struct {
-	EndingBefore string `json:"ending_before"`
-}
-
-// CreditType is the type of the credit used in the credit grant
-type CreditType struct {
-	Name string `json:"name"`
-	ID   string `json:"id"`
-}
-
-// GrantAmountID represents the amount of credits granted with the credit type ID
-// for the create credits grant request
-type GrantAmountID struct {
-	Amount       float64   `json:"amount"`
-	CreditTypeID uuid.UUID `json:"credit_type_id"`
-}
-
-// GrantAmount represents the amount of credits granted with the credit type
-// for the list credit grants response
-type GrantAmount struct {
-	Amount     float64    `json:"amount"`
-	CreditType CreditType `json:"credit_type"`
-}
-
-// PaidAmount represents the amount paid by the customer
-type PaidAmount struct {
-	Amount       float64   `json:"amount"`
-	CreditTypeID uuid.UUID `json:"credit_type_id"`
-}
-
-// PricingUnit represents the unit of the pricing (e.g. USD, MXN, CPU hours)
-type PricingUnit struct {
-	ID         uuid.UUID `json:"id"`
-	Name       string    `json:"name"`
-	IsCurrency bool      `json:"is_currency"`
-}
-
-// Balance represents the effective balance of the grant as of the end of the customer's
-// current billing period.
-type Balance struct {
-	// ExcludingPending is the grant's current balance excluding pending deductions
-	ExcludingPending float64 `json:"excluding_pending"`
-	// IncludingPending is the grant's current balance including pending deductions
-	IncludingPending float64 `json:"including_pending"`
-	// EffectiveAt is a RFC3339 timestamp that can be used to filter credit grants by effective date
-	EffectiveAt string `json:"effective_at"`
-}
-
-// CreditGrant is a grant given to a specific user on a specific plan
-type CreditGrant struct {
-	ID          uuid.UUID   `json:"id"`
-	Name        string      `json:"name"`
-	GrantAmount GrantAmount `json:"grant_amount"`
-	Balance     Balance     `json:"balance"`
-	Reason      string      `json:"reason"`
-	EffectiveAt string      `json:"effective_at"`
-	ExpiresAt   string      `json:"expires_at"`
-}
-
-// BillingEvent represents a Metronome billing event.
-type BillingEvent struct {
-	CustomerID    string                 `json:"customer_id"`
-	EventType     string                 `json:"event_type"`
-	Properties    map[string]interface{} `json:"properties"`
-	TransactionID string                 `json:"transaction_id"`
-	Timestamp     string                 `json:"timestamp"`
-}

+ 0 - 15
api/types/billing_stripe.go

@@ -10,18 +10,3 @@ type PaymentMethod struct {
 	ExpYear      int64  `json:"exp_year"`
 	Default      bool   `json:"is_default"`
 }
-
-// Invoice represents an invoice in the billing system.
-type Invoice struct {
-	// The URL to view the hosted invoice.
-	HostedInvoiceURL string `json:"hosted_invoice_url"`
-	// The status of the invoice.
-	Status string `json:"status"`
-	// RFC 3339 timestamp for when the invoice was created.
-	Created string `json:"created"`
-}
-
-// ListCustomerInvoicesRequest is the request to list invoices for a customer
-type ListCustomerInvoicesRequest struct {
-	Status string `schema:"status"`
-}

+ 89 - 0
api/types/billing_usage.go

@@ -0,0 +1,89 @@
+package types
+
+import "github.com/google/uuid"
+
+// ListCreditGrantsResponse returns the total remaining and granted credits for a customer.
+type ListCreditGrantsResponse struct {
+	RemainingBalanceCents int `json:"remaining_credits"`
+	GrantedBalanceCents   int `json:"granted_credits"`
+}
+
+// ListCustomerUsageRequest is the request to list usage for a customer
+type ListCustomerUsageRequest struct {
+	// PreviousPeriods is the number of previous periods to include in the response.
+	PreviousPeriods int `json:"previous_periods,omitempty"`
+	// CurrentPeriod is whether to return only usage for the current billing period.
+	CurrentPeriod bool `json:"current_period,omitempty"`
+}
+
+// Usage is the aggregated usage for a customer
+type Usage struct {
+	FromDatetime     string        `json:"from_datetime"`
+	ToDatetime       string        `json:"to_datetime"`
+	TotalAmountCents int64         `json:"total_amount_cents"`
+	ChargesUsage     []ChargeUsage `json:"charges_usage"`
+}
+
+// ChargeUsage is the usage for a charge
+type ChargeUsage struct {
+	Units          string         `json:"units"`
+	AmountCents    int64          `json:"amount_cents"`
+	AmountCurrency string         `json:"amount_currency"`
+	BillableMetric BillableMetric `json:"billable_metric"`
+}
+
+// BillableMetric is the metric collected for billing
+type BillableMetric struct {
+	Name string `json:"name"`
+}
+
+// Plan is the plan for a customer
+type Plan struct {
+	ID           string `json:"id"`
+	CustomerID   string `json:"customer_id"`
+	StartingOn   string `json:"starting_on"`
+	EndingBefore string `json:"ending_before"`
+	TrialInfo    Trial  `json:"trial_info,omitempty"`
+}
+
+// Trial contains the information for a trial period
+type Trial struct {
+	EndingBefore string `json:"ending_before"`
+}
+
+// BillingEvent represents a Lago billing event.
+type BillingEvent struct {
+	CustomerID    string                 `json:"customer_id"`
+	EventType     string                 `json:"event_type"`
+	Properties    map[string]interface{} `json:"properties"`
+	TransactionID string                 `json:"transaction_id"`
+	Timestamp     string                 `json:"timestamp"`
+}
+
+// AppliedCoupon represents an applied coupon in the billing system.
+type AppliedCoupon struct {
+	Status                     string `json:"status"`
+	FrequencyDuration          int    `json:"frequency_duration"`
+	FrequencyDurationRemaining int    `json:"frequency_duration_remaining"`
+	CreatedAt                  string `json:"created_at"`
+}
+
+// Wallet represents a customer credits wallet
+type Wallet struct {
+	LagoID                   uuid.UUID `json:"lago_id,omitempty"`
+	Status                   string    `json:"status"`
+	BalanceCents             int       `json:"balance_cents,omitempty"`
+	CreditsOngoingBalance    string    `json:"credits_ongoing_balance,omitempty"`
+	OngoingBalanceCents      int       `json:"ongoing_balance_cents,omitempty"`
+	OngoingUsageBalanceCents int       `json:"ongoing_usage_balance_cents,omitempty"`
+}
+
+// Invoice represents an invoice in the billing system.
+type Invoice struct {
+	// The URL to view the hosted invoice.
+	HostedInvoiceURL string `json:"hosted_invoice_url"`
+	// The status of the invoice.
+	Status string `json:"status"`
+	// RFC 3339 timestamp for when the invoice was created.
+	Created string `json:"created"`
+}

+ 2 - 1
api/types/project.go

@@ -39,8 +39,9 @@ type Project struct {
 	BetaFeaturesEnabled             bool    `json:"beta_features_enabled"`
 	CapiProvisionerEnabled          bool    `json:"capi_provisioner_enabled"`
 	BillingEnabled                  bool    `json:"billing_enabled"`
-	MetronomeEnabled                bool    `json:"metronome_enabled"`
+	LagoEnabled                     bool    `json:"metronome_enabled"`
 	InfisicalEnabled                bool    `json:"infisical_enabled"`
+	FreezeEnabled                   bool    `json:"freeze_enabled"`
 	DBEnabled                       bool    `json:"db_enabled"`
 	EFSEnabled                      bool    `json:"efs_enabled"`
 	EnableReprovision               bool    `json:"enable_reprovision"`

+ 20 - 0
dashboard/src/assets/neon.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
+    <title>Neon</title>
+    <defs>
+        <linearGradient x1="100%" y1="100%" x2="12.0694444%" y2="0%" id="linearGradient-1">
+            <stop stop-color="#62F755" offset="0%"></stop>
+            <stop stop-color="#8FF986" stop-opacity="0" offset="100%"></stop>
+        </linearGradient>
+        <linearGradient x1="100%" y1="100%" x2="40.6027778%" y2="76.8972222%" id="linearGradient-2">
+            <stop stop-color="#000000" stop-opacity="0.9" offset="0%"></stop>
+            <stop stop-color="#1A1A1A" stop-opacity="0" offset="100%"></stop>
+        </linearGradient>
+    </defs>
+    <g>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill="#00E0D9"></path>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill="url(#linearGradient-1)"></path>
+        <path d="M0,44.1386667 C0,19.7615542 19.7615542,0 44.1386667,0 L211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 L44.1386667,256 C19.7615542,256 0,236.238446 0,211.861333 L0,44.1386667 Z M44.1386667,35.3137778 C39.2604444,35.3137778 35.3137778,39.2604444 35.3137778,44.1315556 L35.3137778,211.861333 C35.3137778,216.739556 39.2604444,220.693333 44.1315556,220.693333 L121.820444,220.693333 C124.259556,220.693333 124.906667,218.716444 124.906667,216.277333 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,44.1386667 C220.693333,39.2604444 221.148444,35.3137778 216.277333,35.3137778 L44.1386667,35.3137778 Z" fill-opacity="0.4" fill="url(#linearGradient-2)"></path>
+        <path d="M211.861333,0 C236.238446,0 256,19.7615542 256,44.1386667 L256,186.787556 C256,212.003556 224.085333,222.947556 208.611556,203.043556 L160.220444,140.792889 L160.220444,216.277333 C160.220444,238.215556 142.436001,256 120.497778,256 C121.667088,256 122.788506,255.535493 123.615333,254.708666 C124.44216,253.881839 124.906667,252.760421 124.906667,251.591111 L124.906667,115.057778 C124.906667,89.8346667 156.821333,78.8906667 172.302222,98.8017778 L220.693333,161.045333 L220.693333,8.82488889 C220.693333,3.95377778 216.739556,0 211.861333,0 Z" fill="#63F655"></path>
+    </g>
+</svg>

+ 1 - 0
dashboard/src/assets/plus-square.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-plus"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>

BIN
dashboard/src/assets/quivr.png


+ 15 - 0
dashboard/src/assets/upstash.svg

@@ -0,0 +1,15 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="118" height="118" fill="none">
+    <g clip-path="url(#upstash_icon_dark_bg)">
+        <path fill="#00E9A3" d="M15.105 103.244c19.416 19.526 50.895 19.526 70.311 0 19.416-19.526 19.416-51.185 0-70.711l-8.789 8.839c14.562 14.645 14.562 38.388 0 53.033-14.562 14.644-38.171 14.644-52.733 0l-8.789 8.839Z"/>
+        <path fill="#00E9A3" d="M32.683 85.566c9.708 9.763 25.447 9.763 35.155 0 9.708-9.763 9.708-25.592 0-35.355L59.05 59.05c4.854 4.881 4.854 12.796 0 17.677a12.38 12.38 0 0 1-17.578 0l-8.79 8.839Z"/>
+        <path fill="#00E9A3" d="M102.994 14.855c-19.416-19.526-50.895-19.526-70.311 0-19.416 19.527-19.416 51.185 0 70.711l8.788-8.839c-14.561-14.645-14.561-38.388 0-53.033 14.562-14.644 38.172-14.644 52.734 0l8.789-8.839Z"/>
+        <path fill="#00E9A3" d="M85.416 32.533c-9.708-9.763-25.448-9.763-35.156 0-9.708 9.763-9.708 25.592 0 35.355l8.79-8.839c-4.855-4.881-4.855-12.795 0-17.677a12.38 12.38 0 0 1 17.577 0l8.789-8.839Z"/>
+        <path fill="#fff" fill-opacity=".8" d="M102.994 14.855c-19.416-19.526-50.896-19.526-70.312 0-19.416 19.527-19.416 51.185 0 70.711l8.79-8.839c-14.563-14.645-14.563-38.388 0-53.033 14.561-14.644 38.17-14.644 52.732 0l8.79-8.839Z"/>
+        <path fill="#fff" fill-opacity=".8" d="M85.416 32.533c-9.708-9.763-25.448-9.763-35.156 0-9.708 9.763-9.708 25.592 0 35.355l8.79-8.839c-4.855-4.881-4.855-12.795 0-17.677a12.38 12.38 0 0 1 17.577 0l8.789-8.839Z"/>
+    </g>
+    <defs>
+        <clipPath id="upstash_icon_dark_bg">
+            <path fill="#fff" d="M15 0h88v118H15z"/>
+        </clipPath>
+    </defs>
+</svg>

+ 0 - 15
dashboard/src/components/Boilerplate.tsx

@@ -1,15 +0,0 @@
-import React, { useState } from "react";
-
-import styled from "styled-components";
-
-type Props = {};
-
-const Boilerplate: React.FC<Props> = (props) => {
-  const [someState, setSomeState] = useState("");
-
-  return <StyledBoilerplate></StyledBoilerplate>;
-};
-
-export default Boilerplate;
-
-const StyledBoilerplate = styled.div``;

+ 0 - 299
dashboard/src/components/SOC2Checks.tsx

@@ -1,299 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { withRouter, type RouteComponentProps } from "react-router";
-import styled from "styled-components";
-
-import Container from "components/porter/Container";
-
-import external_link from "assets/external-link.svg";
-import failure from "assets/failure.svg";
-import pending from "assets/pending.svg";
-import healthy from "assets/status-healthy.png";
-
-import Loading from "./Loading";
-import Link from "./porter/Link";
-import Spacer from "./porter/Spacer";
-import Text from "./porter/Text";
-import ToggleRow from "./porter/ToggleRow";
-import { type Soc2Data, type Soc2Check } from "shared/types";
-
-type Props = RouteComponentProps & {
-  soc2Data: Soc2Check;
-  error?: string;
-  enableAll: boolean;
-  setSoc2Data: (x: Soc2Check) => void;
-  readOnly: boolean;
-};
-type ItemProps = RouteComponentProps & {
-  checkKey: string;
-  checkLabel?: string;
-};
-
-const SOC2Checks: React.FC<Props> = ({
-  soc2Data,
-  enableAll,
-  setSoc2Data,
-  readOnly,
-}) => {
-  // const { soc2Data, setSoc2Data } = useContext(Context);
-  const soc2Checks = soc2Data?.soc2_checks || {};
-
-  const combinedKeys = new Set([...Object.keys(soc2Checks)]);
-
-  useEffect(() => {
-    if (enableAll) {
-      const newSOC2Checks = Object.keys(soc2Checks).reduce((acc, key) => {
-        acc[key] = {
-          ...soc2Checks[key],
-          status: soc2Checks[key].enabled
-            ? soc2Checks[key].status === "PENDING_ENABLED"
-              ? "PENDING_ENABLED"
-              : "ENABLED"
-            : "PENDING_ENABLED",
-        };
-        return acc;
-      }, {});
-      setSoc2Data((prev: Soc2Data) => ({
-        ...prev,
-        soc2_checks: newSOC2Checks,
-      }));
-    } else {
-      const newSOC2Checks = Object.keys(soc2Checks).reduce((acc, key) => {
-        acc[key] = {
-          ...soc2Checks[key],
-          status: !soc2Checks[key].enabled
-            ? ""
-            : soc2Checks[key].status === "PENDING_ENABLED"
-              ? "PENDING_ENABLED"
-              : "ENABLED",
-        };
-        return acc;
-      }, {});
-      setSoc2Data((prev: Soc2Data) => ({
-        ...prev,
-        soc2_checks: newSOC2Checks,
-      }));
-    }
-  }, [enableAll]);
-
-  const Soc2Item: React.FC<ItemProps> = ({ checkKey, checkLabel }) => {
-    const checkData = soc2Data?.soc2_checks?.[checkKey];
-    const hasMessage = checkData?.message;
-    const enabled = checkData?.enabled;
-    const status = checkData?.status;
-
-    const [isExpanded, setIsExpanded] = useState(true);
-
-    const handleToggle = (): void => {
-      if (hasMessage && enabled) {
-        setIsExpanded(!isExpanded);
-      }
-    };
-
-    const determineStatus = (currentStatus: string): string => {
-      if (currentStatus === "ENABLED") {
-        return "PENDING_DISABLED";
-      }
-      if (currentStatus === "PENDING_DISABLED") {
-        return "ENABLED";
-      }
-      if (currentStatus === "PENDING_ENABLED") {
-        return "";
-      }
-      if (currentStatus === "") {
-        return "PENDING_ENABLED";
-      }
-    };
-
-    const handleEnable = (): void => {
-      setSoc2Data((prev) => ({
-        ...prev,
-        soc2_checks: {
-          ...prev.soc2_checks,
-          [checkKey]: {
-            ...prev.soc2_checks[checkKey],
-            enabled: !prev.soc2_checks[checkKey].enabled,
-            status: determineStatus(prev.soc2_checks[checkKey].status),
-          },
-        },
-      }));
-    };
-
-    return (
-      <CheckItemContainer hasMessage={hasMessage}>
-        {" "}
-        {/* Pass isExpanded as a prop */}
-        <CheckItemTop onClick={handleToggle}>
-          {status === "LOADING" && (
-            <Loading offset="0px" width="20px" height="20px" />
-          )}
-          {status === "PENDING_ENABLED" && <StatusIcon src={pending} />}
-          {status === "ENABLED" && <StatusIcon src={healthy} />}
-          {(status === "" || status === "PENDING_DISABLED") && (
-            <StatusIcon height="10px" src={failure} />
-          )}
-          <Spacer inline x={1} />
-          <Text style={{ marginLeft: "10px", flex: 1 }}>{checkLabel}</Text>
-          {enabled && (
-            <ExpandIcon className="material-icons" isExpanded={isExpanded}>
-              arrow_drop_down
-            </ExpandIcon>
-          )}
-        </CheckItemTop>
-        {isExpanded && hasMessage && (
-          <div style={{ marginLeft: "10px" }}>
-            <Spacer y={0.5} />
-            <Text>{checkData?.message}</Text>
-            <Spacer y={0.5} />
-            {checkData?.metadata &&
-              Object.entries(checkData.metadata).map(([key, value]) => (
-                <>
-                  <div key={key}>
-                    <ErrorMessageLabel>{key}:</ErrorMessageLabel>
-                    <ErrorMessageContent>{value}</ErrorMessageContent>
-                  </div>
-                </>
-              ))}
-            <Spacer y={0.5} />
-
-            {!checkData?.hideToggle && (
-              <>
-                <Container row spaced style={{ marginRight: "10px" }}>
-                  <ToggleRow
-                    isToggled={enabled || enableAll}
-                    onToggle={() => {
-                      handleEnable();
-                    }}
-                    disabled={
-                      readOnly ||
-                      enableAll ||
-                      (enabled &&
-                        checkData?.locked &&
-                        status !== "PENDING_ENABLED")
-                    }
-                    disabledTooltip={
-                      readOnly
-                        ? "Wait for provisioning to complete before editing this field."
-                        : enableAll
-                          ? "Global SOC 2 setting must be disabled to toggle this"
-                          : checkData?.disabledTooltip
-                    }
-                  >
-                    <Container row>
-                      <Text>{checkData.enabledField}</Text>
-                      <Spacer inline x={1} />
-                      <Text color="helper">{checkData.info}</Text>
-                    </Container>
-                  </ToggleRow>
-
-                  {checkData.link && (
-                    <Link
-                      onClick={() => {
-                        window.open(checkData.link, "_blank");
-                      }}
-                    >
-                      <TagIcon src={external_link} />
-                      More Info
-                    </Link>
-                  )}
-                </Container>
-                <Spacer y={0.5} />
-              </>
-            )}
-          </div>
-        )}
-      </CheckItemContainer>
-    );
-  };
-  return (
-    <>
-      <>
-        {/* <Fieldset>
-        <DonutChart soc2Data={soc2Data} />
-      </Fieldset> */}
-        <Spacer y={1} />
-        <AppearingDiv>
-          {Array.from(combinedKeys).map((checkKey) => (
-            <Soc2Item
-              key={checkKey}
-              checkKey={checkKey}
-              checkLabel={checkKey}
-            />
-          ))}
-        </AppearingDiv>
-      </>
-    </>
-  );
-};
-
-export default withRouter(SOC2Checks);
-
-const AppearingDiv = styled.div<{ color?: string }>`
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-  display: flex;
-  flex-direction: column;
-  color: ${(props) => props.color || "#ffffff44"};
-
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-const StatusIcon = styled.img`
-  height: ${(props) => (props.height ? props.height : "14px")};
-`;
-
-const CheckItemContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  border: ${(props) =>
-    props.isExpanded
-      ? "2px solid #3a48ca"
-      : "1px solid " +
-      props.theme.border}; // Thicker and blue border if expanded
-  border-radius: 5px;
-  font-size: 13px;
-  width: 100%;
-  margin-bottom: 10px;
-  padding-left: 10px;
-  cursor: ${(props) => (props.hasMessage ? "pointer" : "default")};
-  background: ${(props) => props.theme.clickable.bg};
-`;
-
-const CheckItemTop = styled.div`
-  display: flex;
-  align-items: center;
-  padding: 10px;
-  background: ${(props) => props.theme.clickable.bg};
-`;
-
-const ExpandIcon = styled.i<{ isExpanded: boolean }>`
-  margin-left: 8px;
-  color: #ffffff66;
-  font-size: 20px;
-  cursor: pointer;
-  border-radius: 20px;
-  transform: ${(props) => (props.isExpanded ? "" : "rotate(-90deg)")};
-`;
-const ErrorMessageLabel = styled.span`
-  font-weight: bold;
-  margin-left: 10px;
-`;
-const ErrorMessageContent = styled.div`
-  font-family: "Courier New", Courier, monospace;
-  padding: 5px 10px;
-  border-radius: 4px;
-  margin-left: 10px;
-  user-select: text;
-  cursor: text;
-`;
-const TagIcon = styled.img`
-  height: 12px;
-  margin-right: 3px;
-`;

+ 0 - 118
dashboard/src/components/expanded-object/Header.tsx

@@ -1,118 +0,0 @@
-import DynamicLink from "components/DynamicLink";
-import React from "react";
-import styled from "styled-components";
-import TitleSection from "components/TitleSection";
-
-import leftArrow from "assets/left-arrow.svg";
-
-type Props = {
-  last_updated: string;
-  back_link: string;
-  name: string;
-  icon: string;
-  inline_title_items?: React.ReactNodeArray;
-  sub_title_items?: React.ReactNodeArray;
-  materialIconClass?: string;
-};
-
-const Header: React.FunctionComponent<Props> = (props) => {
-  const {
-    last_updated,
-    back_link,
-    icon,
-    name,
-    inline_title_items,
-    sub_title_items,
-    materialIconClass,
-  } = props;
-
-  return (
-    <>
-      <BreadcrumbRow>
-        <Breadcrumb to={back_link}>
-          <ArrowIcon src={leftArrow} />
-          <Wrap>Back</Wrap>
-        </Breadcrumb>
-      </BreadcrumbRow>
-      <HeaderWrapper>
-        <Title
-          icon={icon}
-          iconWidth="25px"
-          materialIconClass={materialIconClass}
-        >
-          {name}
-          <Flex>{inline_title_items}</Flex>
-        </Title>
-
-        {sub_title_items || (
-          <InfoWrapper>
-            <InfoText>Last updated {last_updated}</InfoText>
-          </InfoWrapper>
-        )}
-      </HeaderWrapper>
-    </>
-  );
-};
-
-export default Header;
-
-const Wrap = styled.div`
-  z-index: 999;
-`;
-
-const ArrowIcon = styled.img`
-  width: 15px;
-  margin-right: 8px;
-  opacity: 50%;
-`;
-
-const BreadcrumbRow = styled.div`
-  width: 100%;
-  display: flex;
-  justify-content: flex-start;
-`;
-
-const Breadcrumb = styled(DynamicLink)`
-  color: #aaaabb88;
-  font-size: 13px;
-  margin-bottom: 15px;
-  display: flex;
-  align-items: center;
-  margin-top: -10px;
-  z-index: 999;
-  padding: 5px;
-  padding-right: 7px;
-  border-radius: 5px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
-const HeaderWrapper = styled.div`
-  position: relative;
-  margin-bottom: 10px;
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  width: auto;
-  justify-content: space-between;
-`;
-
-const InfoText = styled.span`
-  font-size: 13px;
-  color: #aaaabb66;
-`;
-
-const Title = styled(TitleSection)`
-  font-size: 16px;
-  margin-top: 4px;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  margin: 10px 0;
-`;

+ 1 - 0
dashboard/src/components/porter/BlockSelect.tsx

@@ -100,6 +100,7 @@ const Block = styled.div<{ selected?: boolean; disabled?: boolean }>`
   display: flex;
   flex-direction: column;
   height: 100%;
+  width: 100%;
   align-items: left;
   user-select: none;
   font-size: 13px;

+ 0 - 34
dashboard/src/components/porter/TemplateComponent.tsx

@@ -1,34 +0,0 @@
-import React, { useEffect, useState } from "react";
-import styled from "styled-components";
-
-type Props = {
-};
-
-const TemplateComponent: React.FC<Props> = ({
-}) => {
-  const [isExpanded, setIsExpanded] = useState(false);
-
-  useEffect(() => {
-    // Do something
-  }, []);
-
-  return (
-    <StyledTemplateComponent>
-    </StyledTemplateComponent>
-  );
-};
-
-export default TemplateComponent;
-
-const StyledTemplateComponent = styled.div`
-width: 100%;
-animation: fadeIn 0.3s 0s;
-@keyframes fadeIn {
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-}
-`;

+ 0 - 103
dashboard/src/components/repo-selector/NewGHAction.tsx

@@ -1,103 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import { ChartType } from "shared/types";
-import { Context } from "shared/Context";
-import InputRow from "components/form-components/InputRow";
-
-import Loading from "../Loading";
-
-type PropsType = {
-  repoName: string;
-  dockerPath: string;
-  grid: number;
-  chart: ChartType;
-  imgURL: string;
-  setURL: (x: string) => void;
-};
-
-type StateType = {
-  trueDockerPath: string;
-  loading: boolean;
-  error: boolean;
-};
-
-export default class NewGHAction extends Component<PropsType, StateType> {
-  state = {
-    dockerRepo: "",
-    trueDockerPath: this.props.dockerPath,
-    loading: false,
-    error: false,
-  };
-
-  componentDidMount() {
-    if (this.props.dockerPath[0] === "/") {
-      this.setState({
-        trueDockerPath: this.props.dockerPath.substring(
-          1,
-          this.props.dockerPath.length
-        ),
-      });
-    }
-  }
-
-  renderConfirmation = () => {
-    let { loading } = this.state;
-    if (loading) {
-      return (
-        <LoadingWrapper>
-          <Loading />
-        </LoadingWrapper>
-      );
-    }
-
-    return (
-      <Holder>
-        <InputRow
-          disabled={true}
-          label="Git Repository"
-          type="text"
-          width="100%"
-          value={this.props.repoName}
-          setValue={(x: string) => console.log(x)}
-        />
-        <InputRow
-          disabled={true}
-          label="Dockerfile Path"
-          type="text"
-          width="100%"
-          value={this.state.trueDockerPath}
-          setValue={(x: string) => console.log(x)}
-        />
-        <InputRow
-          label="Docker Image Repository"
-          placeholder="Image Repo URL (ex. gcr.io/porter/mr-p)"
-          type="text"
-          width="100%"
-          value={this.props.imgURL}
-          setValue={(x: string) => this.props.setURL(x)}
-        />
-      </Holder>
-    );
-  };
-
-  render() {
-    return <div>{this.renderConfirmation()}</div>;
-  }
-}
-
-NewGHAction.contextType = Context;
-
-const Holder = styled.div`
-  padding: 0px 12px;
-`;
-
-const LoadingWrapper = styled.div`
-  padding: 30px 0px;
-  background: #ffffff11;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 13px;
-  color: #ffffff44;
-`;

+ 0 - 1
dashboard/src/lib/addons/deepgram.ts

@@ -6,7 +6,6 @@ export const deepgramConfigValidator = z.object({
   quayUsername: z.string().nonempty().default("username"),
   quaySecret: z.string().nonempty().default("secret"),
   quayEmail: z.string(),
-  instanceType: z.literal("g4dn.xlarge"),
   releaseTag: z.string().nonempty().default("release-240426"),
 });
 

+ 60 - 2
dashboard/src/lib/addons/index.ts

@@ -8,6 +8,7 @@ import {
   Mezmo,
   Newrelic,
   Postgres,
+  Quivr,
   Redis,
   Tailscale,
 } from "@porter-dev/api-contracts/src/porter/v1/addons_pb";
@@ -22,6 +23,7 @@ import { metabaseConfigValidator } from "./metabase";
 import { mezmoConfigValidator } from "./mezmo";
 import { newrelicConfigValidator } from "./newrelic";
 import { defaultPostgresAddon, postgresConfigValidator } from "./postgres";
+import { quivrConfigValidator } from "./quivr";
 import { redisConfigValidator } from "./redis";
 import { tailscaleConfigValidator } from "./tailscale";
 import {
@@ -31,6 +33,7 @@ import {
   ADDON_TEMPLATE_MEZMO,
   ADDON_TEMPLATE_NEWRELIC,
   ADDON_TEMPLATE_POSTGRES,
+  ADDON_TEMPLATE_QUIVR,
   ADDON_TEMPLATE_REDIS,
   ADDON_TEMPLATE_TAILSCALE,
   type AddonTemplate,
@@ -59,6 +62,7 @@ export const clientAddonValidator = z.object({
     newrelicConfigValidator,
     tailscaleConfigValidator,
     deepgramConfigValidator,
+    quivrConfigValidator,
   ]),
 });
 export type ClientAddonType = z.infer<
@@ -174,6 +178,16 @@ export function defaultClientAddon(
       }),
       template: ADDON_TEMPLATE_DEEPGRAM,
     }))
+    .with("quivr", () => ({
+      ...clientAddonValidator.parse({
+        expanded: true,
+        name: { readOnly: false, value: "quivr" },
+        config: quivrConfigValidator.parse({
+          type: "quivr",
+        }),
+      }),
+      template: ADDON_TEMPLATE_QUIVR,
+    }))
     .exhaustive();
 }
 
@@ -187,6 +201,7 @@ function addonTypeEnumProto(type: ClientAddon["config"]["type"]): AddonType {
     .with("newrelic", () => AddonType.NEWRELIC)
     .with("tailscale", () => AddonType.TAILSCALE)
     .with("deepgram", () => AddonType.DEEPGRAM)
+    .with("quivr", () => AddonType.QUIVR)
     .exhaustive();
 }
 
@@ -282,11 +297,35 @@ export function clientAddonToProto(
         ecrUsername: data.quayUsername,
         ecrPassword: data.quaySecret,
         ecrEmail: data.quayEmail,
-        instanceType: data.instanceType,
         releaseTag: data.releaseTag,
       }),
       case: "deepgram" as const,
     }))
+    .with({ type: "quivr" }, (data) => ({
+      value: new Quivr({
+        ingressEnabled: data.exposedToExternalTraffic,
+        domains: [
+          {
+            name: data.customDomain,
+            type: DomainType.UNSPECIFIED,
+          },
+          {
+            name: data.porterDomain,
+            type: DomainType.PORTER,
+          },
+          // if not exposed, remove all domains
+        ].filter((d) => d.name !== "" && data.exposedToExternalTraffic),
+        openaiApiKey: data.openAiApiKey,
+        supabaseUrl: data.supabaseUrl,
+        supabaseServiceKey: data.supabaseServiceKey,
+        pgDatabaseUrl: data.pgDatabaseUrl,
+        jwtSecretKey: data.jwtSecretKey,
+        quivrDomain: data.quivrDomain,
+        anthropicApiKey: data.anthropicApiKey,
+        cohereApiKey: data.cohereApiKey,
+      }),
+      case: "quivr" as const,
+    }))
     .exhaustive();
 
   const proto = new Addon({
@@ -405,7 +444,25 @@ export function clientAddonFromProto({
       quaySecret: data.value.ecrPassword ?? "",
       quayEmail: data.value.ecrEmail ?? "",
       releaseTag: data.value.releaseTag ?? "",
-      instanceType: "g4dn.xlarge" as const,
+    }))
+    .with({ case: "quivr" }, (data) => ({
+      type: "quivr" as const,
+      exposedToExternalTraffic: data.value.ingressEnabled ?? false,
+      porterDomain:
+        data.value.domains.find((domain) => domain.type === DomainType.PORTER)
+          ?.name ?? "",
+      customDomain:
+        data.value.domains.find(
+          (domain) => domain.type === DomainType.UNSPECIFIED
+        )?.name ?? "",
+      openAiApiKey: data.value.openaiApiKey ?? "",
+      supabaseUrl: data.value.supabaseUrl ?? "",
+      supabaseServiceKey: data.value.supabaseServiceKey ?? "",
+      pgDatabaseUrl: data.value.pgDatabaseUrl ?? "",
+      jwtSecretKey: data.value.jwtSecretKey ?? "",
+      quivrDomain: data.value.quivrDomain ?? "",
+      anthropicApiKey: data.value.anthropicApiKey ?? "",
+      cohereApiKey: data.value.cohereApiKey ?? "",
     }))
     .exhaustive();
 
@@ -418,6 +475,7 @@ export function clientAddonFromProto({
     .with({ case: "newrelic" }, () => ADDON_TEMPLATE_NEWRELIC)
     .with({ case: "tailscale" }, () => ADDON_TEMPLATE_TAILSCALE)
     .with({ case: "deepgram" }, () => ADDON_TEMPLATE_DEEPGRAM)
+    .with({ case: "quivr" }, () => ADDON_TEMPLATE_QUIVR)
     .exhaustive();
 
   const clientAddon = {

+ 20 - 0
dashboard/src/lib/addons/quivr.ts

@@ -0,0 +1,20 @@
+import { z } from "zod";
+
+export const quivrConfigValidator = z.object({
+  type: z.literal("quivr"),
+  exposedToExternalTraffic: z.boolean().default(true),
+  porterDomain: z.string().default(""),
+  customDomain: z.string().default(""),
+  openAiApiKey: z.string().nonempty().default("*******"),
+  supabaseUrl: z.string().nonempty().default("https://*******.supabase.co"),
+  supabaseServiceKey: z.string().nonempty().default("*******"),
+  pgDatabaseUrl: z
+    .string()
+    .nonempty()
+    .default("postgres://postgres:postgres@localhost:5432/quivr"),
+  jwtSecretKey: z.string().nonempty().default("*******"),
+  quivrDomain: z.string().nonempty().default("https://*******.quivr.co"),
+  anthropicApiKey: z.string().nonempty().default("*******"),
+  cohereApiKey: z.string().nonempty().default("*******"),
+});
+export type QuivrConfigValidator = z.infer<typeof quivrConfigValidator>;

+ 56 - 2
dashboard/src/lib/addons/template.ts

@@ -5,9 +5,12 @@ import DeepgramForm from "main/home/add-on-dashboard/deepgram/DeepgramForm";
 import MetabaseForm from "main/home/add-on-dashboard/metabase/MetabaseForm";
 import MezmoForm from "main/home/add-on-dashboard/mezmo/MezmoForm";
 import NewRelicForm from "main/home/add-on-dashboard/newrelic/NewRelicForm";
+import QuivrForm from "main/home/add-on-dashboard/quivr/QuivrForm";
 import TailscaleForm from "main/home/add-on-dashboard/tailscale/TailscaleForm";
 import TailscaleOverview from "main/home/add-on-dashboard/tailscale/TailscaleOverview";
 
+import quivr from "assets/quivr.png";
+
 import { type ClientAddon, type ClientAddonType } from ".";
 
 export type AddonTemplateTag =
@@ -293,6 +296,17 @@ export const ADDON_TEMPLATE_DEEPGRAM: AddonTemplate<"deepgram"> = {
       displayName: "Configuration",
       component: DeepgramForm,
     },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
   ],
   defaultValues: {
     type: "deepgram",
@@ -300,18 +314,58 @@ export const ADDON_TEMPLATE_DEEPGRAM: AddonTemplate<"deepgram"> = {
     quayUsername: "",
     quaySecret: "",
     quayEmail: "",
-    instanceType: "g4dn.xlarge",
+    releaseTag: "v1.0.0",
   },
   isModelTemplate: true,
 };
+export const ADDON_TEMPLATE_QUIVR: AddonTemplate<"quivr"> = {
+  type: "quivr",
+  displayName: "Quivr",
+  description: "Your second brain, empowered by generative AI",
+  icon: quivr,
+  tags: ["Analytics"],
+  tabs: [
+    {
+      name: "configuration",
+      displayName: "Configuration",
+      component: QuivrForm,
+    },
+    {
+      name: "logs",
+      displayName: "Logs",
+      component: Logs,
+      isOnlyForPorterOperators: true,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: Settings,
+    },
+  ],
+  defaultValues: {
+    type: "quivr",
+    exposedToExternalTraffic: true,
+    porterDomain: "",
+    customDomain: "",
+    openAiApiKey: "",
+    supabaseUrl: "",
+    supabaseServiceKey: "",
+    pgDatabaseUrl: "",
+    jwtSecretKey: "",
+    quivrDomain: "https://chat.quivr.com",
+    anthropicApiKey: "",
+    cohereApiKey: "",
+  },
+};
 
 export const SUPPORTED_ADDON_TEMPLATES: Array<AddonTemplate<ClientAddonType>> =
   [
     ADDON_TEMPLATE_DATADOG,
     ADDON_TEMPLATE_MEZMO,
     ADDON_TEMPLATE_METABASE,
-    ADDON_TEMPLATE_NEWRELIC,
+    // ADDON_TEMPLATE_NEWRELIC,
     ADDON_TEMPLATE_TAILSCALE,
+    ADDON_TEMPLATE_QUIVR,
   ];
 
 export const SUPPORTED_MODEL_ADDON_TEMPLATES: Array<

+ 16 - 22
dashboard/src/lib/billing/types.tsx

@@ -20,29 +20,31 @@ export type Plan = z.infer<typeof PlanValidator>;
 export const PlanValidator = z
   .object({
     id: z.string(),
-    plan_name: z.string(),
-    plan_description: z.string(),
     starting_on: z.string(),
+    ending_before: z.string(),
     trial_info: TrialValidator,
   })
   .nullable();
 
-export type UsageMetric = z.infer<typeof UsageMetricValidator>;
-export const UsageMetricValidator = z.object({
-  // starting_on and ending_before are RFC 3339 date strings
-  // that represent the timeframe where the metric was ingested.
-  // If the granularity is set per day, the starting_on field
-  // represents the day the metric was ingested.
-  starting_on: z.string(),
-  ending_before: z.string(),
-  value: z.number(),
+export type BillableMetric = z.infer<typeof BillableMetricValidator>;
+export const BillableMetricValidator = z.object({
+  name: z.string(),
+});
+
+export type ChargeUsage = z.infer<typeof ChargeUsageValidator>;
+export const ChargeUsageValidator = z.object({
+  units: z.string(),
+  amount_cents: z.number(),
+  amount_currency: z.string(),
+  billable_metric: BillableMetricValidator,
 });
 
-export type UsageList = Usage[];
 export type Usage = z.infer<typeof UsageValidator>;
 export const UsageValidator = z.object({
-  metric_name: z.string(),
-  usage_metrics: z.array(UsageMetricValidator),
+  from_datetime: z.string(),
+  to_datetime: z.string(),
+  total_amount_cents: z.number(),
+  charges_usage: z.array(ChargeUsageValidator),
 });
 
 export type CreditGrants = z.infer<typeof CreditGrantsValidator>;
@@ -51,14 +53,6 @@ export const CreditGrantsValidator = z.object({
   remaining_credits: z.number(),
 });
 
-export type CostList = Cost[];
-export type Cost = z.infer<typeof CostValidator>;
-export const CostValidator = z.object({
-  start_timestamp: z.string(),
-  end_timestamp: z.string(),
-  cost: z.number(),
-});
-
 export type InvoiceList = Invoice[];
 export type Invoice = z.infer<typeof InvoiceValidator>;
 export const InvoiceValidator = z.object({

+ 120 - 0
dashboard/src/lib/clusters/constants.ts

@@ -941,6 +941,126 @@ const SUPPORTED_GCP_MACHINE_TYPES: ClientMachineType[] = [
     cpuCores: 44,
     ramMegabytes: 90112,
   },
+  {
+    name: "c3d-highcpu-4",
+    displayName: "c3d-highcpu-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 4,
+    ramMegabytes: 8000,
+  },
+  {
+    name: "c3d-highcpu-8",
+    displayName: "c3d-highcpu-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 8,
+    ramMegabytes: 16000,
+  },
+  {
+    name: "c3d-highcpu-16",
+    displayName: "c3d-highcpu-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 16,
+    ramMegabytes: 32000,
+  },
+  {
+    name: "c3d-highcpu-60",
+    displayName: "c3d-highcpu-60",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 60,
+    ramMegabytes: 118000,
+  },
+  {
+    name: "c3d-highcpu-90",
+    displayName: "c3d-highcpu-90",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 90,
+    ramMegabytes: 177000,
+  },
+  {
+    name: "c3d-highcpu-180",
+    displayName: "c3d-highcpu-180",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 180,
+    ramMegabytes: 354000,
+  },
+  {
+    name: "c3d-highcpu-360",
+    displayName: "c3d-highcpu-360",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 360,
+    ramMegabytes: 708000,
+  },
+  {
+    name: "c3d-standard-4",
+    displayName: "c3d-standard-4",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 4,
+    ramMegabytes: 16000,
+  },
+  {
+    name: "c3d-standard-8",
+    displayName: "c3d-standard-8",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 8,
+    ramMegabytes: 32000,
+  },
+  {
+    name: "c3d-standard-16",
+    displayName: "c3d-standard-16",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 16,
+    ramMegabytes: 64000,
+  },
+  {
+    name: "c3d-standard-30",
+    displayName: "c3d-standard-30",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 30,
+    ramMegabytes: 120000,
+  },
+  {
+    name: "c3d-standard-60",
+    displayName: "c3d-standard-60",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 60,
+    ramMegabytes: 240000,
+  },
+  {
+    name: "c3d-standard-90",
+    displayName: "c3d-standard-90",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 90,
+    ramMegabytes: 360000,
+  },
+  {
+    name: "c3d-standard-180",
+    displayName: "c3d-standard-180",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 180,
+    ramMegabytes: 720000,
+  },
+  {
+    name: "c3d-standard-360",
+    displayName: "c3d-standard-360",
+    supportedRegions: SUPPORTED_GCP_REGIONS.map((r) => r.name),
+    isGPU: false,
+    cpuCores: 360,
+    ramMegabytes: 1440000,
+  },
   {
     name: "c3-highmem-4",
     displayName: "c3-highmem-4",

+ 16 - 0
dashboard/src/lib/clusters/types.ts

@@ -227,6 +227,22 @@ const gcpMachineTypeValidator = z.enum([
   "c3-highmem-8",
   "c3-highmem-22",
   "c3-highmem-44",
+  "c3d-highcpu-4",
+  "c3d-highcpu-8",
+  "c3d-highcpu-16",
+  "c3d-highcpu-30",
+  "c3d-highcpu-60",
+  "c3d-highcpu-90",
+  "c3d-highcpu-180",
+  "c3d-highcpu-360",
+  "c3d-standard-4",
+  "c3d-standard-8",
+  "c3d-standard-16",
+  "c3d-standard-30",
+  "c3d-standard-60",
+  "c3d-standard-90",
+  "c3d-standard-180",
+  "c3d-standard-360",
   "n1-standard-1",
   "n1-standard-2",
   "n1-standard-4",

+ 34 - 1
dashboard/src/lib/databases/types.ts

@@ -36,6 +36,8 @@ const datastoreTypeValidator = z.enum([
   "ELASTICACHE",
   "MANAGED_REDIS",
   "MANAGED_POSTGRES",
+  "NEON",
+  "UPSTASH",
 ]);
 const datastoreEngineValidator = z.enum([
   "UNKNOWN",
@@ -109,6 +111,14 @@ export const DATASTORE_TYPE_MANAGED_REDIS: DatastoreType = {
   name: "MANAGED_REDIS" as const,
   displayName: "Managed Redis",
 };
+export const DATASTORE_TYPE_NEON: DatastoreType = {
+  name: "NEON" as const,
+  displayName: "Neon",
+};
+export const DATASTORE_TYPE_UPSTASH: DatastoreType = {
+  name: "UPSTASH" as const,
+  displayName: "Upstash",
+};
 
 export type DatastoreState = {
   state: z.infer<typeof datastoreValidator>["status"];
@@ -159,6 +169,19 @@ export const DATASTORE_STATE_DELETED: DatastoreState = {
   displayName: "Wrapping up",
 };
 
+export type DatastoreTab = {
+  name: string;
+  displayName: string;
+  component: React.FC;
+  isOnlyForPorterOperators?: boolean;
+};
+
+export const DEFAULT_DATASTORE_TAB = {
+  name: "configuration",
+  displayName: "Configuration",
+  component: () => null,
+};
+
 export type DatastoreTemplate = {
   highLevelType: DatastoreEngine; // this was created so that rds aurora postgres and rds postgres can be grouped together
   type: DatastoreType;
@@ -170,9 +193,9 @@ export type DatastoreTemplate = {
   disabled: boolean;
   instanceTiers: ResourceOption[];
   supportedEngineVersions: EngineVersion[];
-  formTitle: string;
   creationStateProgression: DatastoreState[];
   deletionStateProgression: DatastoreState[];
+  tabs: DatastoreTab[]; // this what is rendered on the dashboard after the datastore is deployed
 };
 
 const instanceTierValidator = z.enum([
@@ -312,6 +335,14 @@ const managedRedisConfigValidator = z.object({
     .default(1),
 });
 
+const neonValidator = z.object({
+  type: z.literal("neon"),
+});
+
+const upstashValidator = z.object({
+  type: z.literal("upstash"),
+});
+
 export const dbFormValidator = z.object({
   name: z
     .string()
@@ -332,6 +363,8 @@ export const dbFormValidator = z.object({
     elasticacheRedisConfigValidator,
     managedRedisConfigValidator,
     managedPostgresConfigValidator,
+    neonValidator,
+    upstashValidator,
   ]),
   clusterId: z.number(),
 });

+ 12 - 0
dashboard/src/lib/environments/types.ts

@@ -0,0 +1,12 @@
+import { z } from "zod";
+
+import { clientAddonValidator } from "lib/addons";
+import { clientAppValidator } from "lib/porter-apps";
+
+const environmentValidator = z.object({
+  name: z.string(),
+  apps: z.array(clientAppValidator),
+  addons: z.array(clientAddonValidator),
+});
+
+export type Environment = z.infer<typeof environmentValidator>;

+ 53 - 0
dashboard/src/lib/hooks/useAuthWindow.ts

@@ -0,0 +1,53 @@
+import { useEffect, useState } from "react";
+
+/**
+ *  Hook to open an authentication window at a given url.
+ *  Once the auth flow redirects back to Porter, the window is closed.
+ */
+export const useAuthWindow = ({
+  authUrl,
+}: {
+  authUrl: string;
+}): {
+  openAuthWindow: () => void;
+} => {
+  const [authWindow, setAuthWindow] = useState<Window | null>(null);
+
+  const openAuthWindow = (): void => {
+    const windowObjectReference = window.open(
+      authUrl,
+      "porterAuthWindow",
+      "width=600,height=700,left=200,top=200"
+    );
+    setAuthWindow(windowObjectReference);
+  };
+
+  useEffect(() => {
+    const interval = setInterval(() => {
+      if (authWindow) {
+        try {
+          if (
+            authWindow.location.hostname.includes("dashboard.getporter.dev") ||
+            authWindow.location.hostname.includes("cloud.porter.run") ||
+            authWindow.location.hostname.includes("localhost")
+          ) {
+            authWindow.close();
+            setAuthWindow(null);
+            clearInterval(interval);
+          }
+        } catch (e) {
+          console.log("Error accessing the authentication window.", e);
+        }
+      }
+    }, 1000);
+
+    return () => {
+      clearInterval(interval);
+      if (authWindow) {
+        authWindow.close();
+      }
+    };
+  }, [authWindow]);
+
+  return { openAuthWindow };
+};

+ 0 - 449
dashboard/src/lib/hooks/useClusterResourceLimits.ts

@@ -1,449 +0,0 @@
-import { useEffect, useState } from "react";
-import {
-  Contract,
-  GKENodePoolType,
-  LoadBalancerType,
-  NodeGroupType,
-  NodePoolType,
-} from "@porter-dev/api-contracts";
-import { useQuery } from "@tanstack/react-query";
-import convert from "convert";
-import { match } from "ts-pattern";
-import { z } from "zod";
-
-import { azureMachineTypeDetails } from "components/azureUtils";
-import { AWS_INSTANCE_LIMITS } from "main/home/app-dashboard/validate-apply/services-settings/tabs/utils";
-
-import api from "shared/api";
-
-const DEFAULT_INSTANCE_CLASS = "t3";
-const DEFAULT_INSTANCE_SIZE = "medium";
-
-export type ClientLoadBalancerType = "ALB" | "NLB" | "UNSPECIFIED";
-
-const encodedContractValidator = z.object({
-  ID: z.number(),
-  CreatedAt: z.string(),
-  UpdatedAt: z.string(),
-  DeletedAt: z.string().nullable(),
-  id: z.string(),
-  base64_contract: z.string(),
-  cluster_id: z.number(),
-  project_id: z.number(),
-  condition: z.string(),
-  condition_metadata: z.record(z.any()).nullable(),
-});
-
-export type NodeGroup = {
-  instanceType: string;
-  minInstances: number;
-  maxInstances: number;
-  nodeGroupType: string;
-  isStateful?: boolean;
-};
-
-export type EksKind = {
-  clusterName: string;
-  clusterVersion: string;
-  cidrRange: string;
-  region: string;
-  nodeGroups: NodeGroup[];
-  loadBalancer: {
-    loadBalancerType: string;
-  };
-  logging: Record<string, unknown>;
-  network: {
-    vpcCidr: string;
-    serviceCidr: string;
-  };
-};
-
-export type GKEKind = {
-  clusterName: string;
-  clusterVersion: string;
-  region: string;
-  nodePools: NodePools[];
-  user: {
-    id: number;
-  };
-  network: {
-    cidrRange: string;
-    controlPlaneCidr: string;
-    podCidr: string;
-    serviceCidr: string;
-  };
-};
-
-export type NodePools = {
-  instanceType: string;
-  minInstances: number;
-  maxInstances: number;
-  nodePoolType: string;
-  isStateful?: boolean;
-  additionalTaints?: string[];
-};
-
-const clusterNodesValidator = z
-  .object({
-    labels: z
-      .object({
-        "beta.kubernetes.io/instance-type": z.string().nullish(),
-        "porter.run/workload-kind": z.string().nullish(),
-      })
-      .optional(),
-  })
-  .transform((data) => {
-    const defaultResources = {
-      maxCPU:
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU,
-      maxRAM:
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
-      maxGPU: 1,
-    };
-    if (!data.labels) {
-      return defaultResources;
-    }
-    const workloadKind = data.labels["porter.run/workload-kind"];
-    if (
-      !workloadKind ||
-      (workloadKind !== "application" && workloadKind !== "custom")
-    ) {
-      return defaultResources;
-    }
-    const instanceType = data.labels["beta.kubernetes.io/instance-type"];
-
-    if (!instanceType) {
-      return defaultResources;
-    }
-
-    // Azure instance types are all prefixed with "Standard_"
-    if (instanceType.startsWith("Standard_")) {
-      const azureMachineType = azureMachineTypeDetails(instanceType);
-      if (azureMachineType) {
-        const { vCPU, RAM, GPU } = azureMachineType.resources;
-        return {
-          maxCPU: vCPU,
-          maxRAM: RAM,
-          maxGPU: GPU || 1,
-        };
-      } else {
-        return defaultResources;
-      }
-    }
-
-    let parsedType;
-    if (instanceType && instanceType.includes(".")) {
-      parsedType = z
-        .tuple([z.string(), z.string()])
-        .safeParse(instanceType.split("."));
-    } else if (instanceType && instanceType.includes("-")) {
-      const [instanceClass, ...instanceSizeParts] = instanceType.split("-");
-      const instanceSize = instanceSizeParts.join("-");
-      parsedType = z
-        .tuple([z.string(), z.string()])
-        .safeParse([instanceClass, instanceSize]);
-    } else {
-      return defaultResources; // Return defaults if instanceType format is not recognized
-    }
-
-    if (!parsedType.success) {
-      return defaultResources;
-    }
-
-    const [instanceClass, instanceSize] = parsedType.data;
-    if (AWS_INSTANCE_LIMITS[instanceClass]?.[instanceSize]) {
-      const { vCPU, RAM, GPU } =
-        AWS_INSTANCE_LIMITS[instanceClass][instanceSize];
-      return {
-        maxCPU: vCPU,
-        maxRAM: RAM,
-        maxGPU: GPU || 1,
-      };
-    }
-    return defaultResources;
-  });
-
-export const useClusterResourceLimits = ({
-  projectId,
-  clusterId,
-  clusterStatus,
-}: {
-  projectId: number | undefined;
-  clusterId: number | undefined;
-  clusterStatus: string | undefined;
-}): {
-  maxCPU: number;
-  maxRAM: number;
-  // defaults indicate the resources assigned to new services
-  defaultCPU: number;
-  defaultRAM: number;
-  clusterContainsGPUNodes: boolean;
-  maxGPU: number;
-  clusterIngressIp: string;
-  loadBalancerType: ClientLoadBalancerType;
-} => {
-  const SMALL_INSTANCE_UPPER_BOUND = 0.75;
-  const LARGE_INSTANCE_UPPER_BOUND = 0.9;
-  const DEFAULT_MULTIPLIER = 0.125;
-  const [clusterContainsGPUNodes, setClusterContainsGPUNodes] = useState(false);
-  const [maxGPU, setMaxGPU] = useState(1);
-  const [maxCPU, setMaxCPU] = useState(
-    AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU *
-      SMALL_INSTANCE_UPPER_BOUND
-  );
-  const [maxRAM, setMaxRAM] = useState(
-    // round to nearest 100
-    Math.round(
-      (convert(
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
-        "GiB"
-      ).to("MB") *
-        SMALL_INSTANCE_UPPER_BOUND) /
-        100
-    ) * 100
-  );
-  const [defaultCPU, setDefaultCPU] = useState(
-    AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].vCPU *
-      DEFAULT_MULTIPLIER
-  );
-  const [defaultRAM, setDefaultRAM] = useState(
-    // round to nearest 100
-    Math.round(
-      (convert(
-        AWS_INSTANCE_LIMITS[DEFAULT_INSTANCE_CLASS][DEFAULT_INSTANCE_SIZE].RAM,
-        "GiB"
-      ).to("MB") *
-        DEFAULT_MULTIPLIER) /
-        100
-    ) * 100
-  );
-  const [clusterIngressIp, setClusterIngressIp] = useState<string>("");
-  const [loadBalancerType, setLoadBalancerType] =
-    useState<ClientLoadBalancerType>("UNSPECIFIED");
-
-  const getClusterNodes = useQuery(
-    ["getClusterNodes", projectId, clusterId],
-    async () => {
-      if (!projectId || !clusterId || clusterId === -1) {
-        return await Promise.resolve([]);
-      }
-
-      const res = await api.getClusterNodes(
-        "<token>",
-        {},
-        {
-          project_id: projectId,
-          cluster_id: clusterId,
-        }
-      );
-      return await z.array(clusterNodesValidator).parseAsync(res.data);
-    },
-    {
-      enabled: !!projectId && !!clusterId,
-      refetchOnWindowFocus: false,
-      retry: false,
-    }
-  );
-
-  const { data: contract } = useQuery(
-    ["getContracts", projectId, clusterId, clusterStatus],
-    async () => {
-      if (!projectId || !clusterId || clusterId === -1) {
-        return;
-      }
-
-      const res = await api.getContracts(
-        "<token>",
-        {},
-        { project_id: projectId }
-      );
-      const contracts = await z
-        .array(encodedContractValidator)
-        .parseAsync(res.data);
-      if (contracts.length) {
-        const latestContract = contracts
-          .filter((contract) => contract.cluster_id === clusterId)
-          .sort(
-            (a, b) =>
-              new Date(b.CreatedAt).getTime() - new Date(a.CreatedAt).getTime()
-          )[0];
-        const decodedContract = Contract.fromJsonString(
-          atob(latestContract.base64_contract)
-        );
-        return decodedContract.cluster;
-      }
-    },
-    {
-      enabled: !!projectId,
-      refetchOnWindowFocus: false,
-      retry: false,
-    }
-  );
-
-  useEffect(() => {
-    if (getClusterNodes.isSuccess) {
-      const data = getClusterNodes.data;
-      if (data.length) {
-        // this logic handles CPU and RAM independently - we might want to change this later
-        const maxCPU = data.reduce((acc, curr) => {
-          return Math.max(acc, curr.maxCPU);
-        }, 0);
-        const maxRAM = data.reduce((acc, curr) => {
-          return Math.max(acc, curr.maxRAM);
-        }, 0);
-        const maxGPU = data.reduce((acc, curr) => {
-          return Math.max(acc, curr.maxGPU);
-        }, 0);
-        let maxMultiplier = SMALL_INSTANCE_UPPER_BOUND;
-        // if the instance type has more than 16 GB ram, we use 90% of the ram/cpu
-        // otherwise, we use 75%
-        if (maxRAM > 16) {
-          maxMultiplier = LARGE_INSTANCE_UPPER_BOUND;
-        }
-        // round down to nearest 0.5 cores
-        const newMaxCPU = Math.floor(maxCPU * maxMultiplier * 2) / 2;
-        // round down to nearest 100 MB
-        const newMaxRAM =
-          Math.round((convert(maxRAM, "GiB").to("MB") * maxMultiplier) / 100) *
-          100;
-        setMaxCPU(newMaxCPU);
-        setMaxRAM(newMaxRAM);
-        setMaxGPU(maxGPU);
-        setDefaultCPU(Number((newMaxCPU * DEFAULT_MULTIPLIER).toFixed(2)));
-        setDefaultRAM(Number((newMaxRAM * DEFAULT_MULTIPLIER).toFixed(0)));
-      }
-    }
-  }, [getClusterNodes]);
-
-  const getCluster = useQuery(
-    ["getClusterIngressIp", projectId, clusterId],
-    async () => {
-      if (!projectId || !clusterId || clusterId === -1) {
-        return await Promise.resolve({ ingress_ip: "" });
-      }
-
-      const res = await api.getCluster(
-        "<token>",
-        {},
-        {
-          project_id: projectId,
-          cluster_id: clusterId,
-        }
-      );
-
-      return await z.object({ ingress_ip: z.string() }).parseAsync(res.data);
-    },
-    {
-      enabled: !!projectId && !!clusterId,
-      refetchOnWindowFocus: false,
-      retry: false,
-    }
-  );
-
-  useEffect(() => {
-    if (getCluster.isSuccess) {
-      setClusterIngressIp(getCluster.data.ingress_ip);
-    }
-  }, [getCluster]);
-
-  useEffect(() => {
-    if (contract) {
-      const containsCustomNodeGroup = match(contract)
-        .with({ kindValues: { case: "eksKind" } }, (c) => {
-          return c.kindValues.value.nodeGroups.some(
-            (ng) =>
-              (ng.nodeGroupType === NodeGroupType.CUSTOM &&
-                (ng.instanceType.includes("g4dn") ||
-                  ng.instanceType.includes("p4d"))) ||
-              (ng.nodeGroupType === NodeGroupType.APPLICATION &&
-                (ng.instanceType.includes("g4dn") ||
-                  ng.instanceType.includes("p4d")))
-          );
-        })
-        .with({ kindValues: { case: "gkeKind" } }, (c) => {
-          return c.kindValues.value.nodePools.some(
-            (ng) =>
-              (ng.nodePoolType === GKENodePoolType.GKE_NODE_POOL_TYPE_CUSTOM &&
-                ng.instanceType.includes("n1")) ||
-              (ng.nodePoolType ===
-                GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION &&
-                ng.instanceType.includes("n1"))
-          );
-        })
-        .with({ kindValues: { case: "aksKind" } }, (c) => {
-          return c.kindValues.value.nodePools.some(
-            (ng) => ng.nodePoolType === NodePoolType.CUSTOM
-          );
-        })
-        .otherwise(() => false);
-
-      const loadBalancerType: ClientLoadBalancerType = match(contract)
-        .with({ kindValues: { case: "eksKind" } }, (c) => {
-          const loadBalancer = c.kindValues.value.loadBalancer;
-          if (!loadBalancer) {
-            return "UNSPECIFIED";
-          }
-          return match(loadBalancer.loadBalancerType)
-            .with(LoadBalancerType.ALB, (): ClientLoadBalancerType => "ALB")
-            .with(LoadBalancerType.NLB, (): ClientLoadBalancerType => "NLB")
-            .otherwise((): ClientLoadBalancerType => "UNSPECIFIED");
-        })
-        .otherwise(() => "UNSPECIFIED");
-
-      // console.log(gpu);
-      // setMaxGPU(gpu);
-      setClusterContainsGPUNodes(containsCustomNodeGroup);
-      setLoadBalancerType(loadBalancerType);
-    }
-  }, [contract]);
-
-  return {
-    maxCPU,
-    maxRAM,
-    defaultCPU,
-    defaultRAM,
-    clusterContainsGPUNodes,
-    maxGPU,
-    clusterIngressIp,
-    loadBalancerType,
-  };
-};
-
-// this function returns the fraction which the resource sliders 'snap' to when the user turns on smart optimization
-export const lowestClosestResourceMultipler = (
-  min: number,
-  max: number,
-  value: number
-): number => {
-  const fractions = [0.5, 0.25, 0.125];
-
-  for (const fraction of fractions) {
-    const newValue = fraction * (max - min) + min;
-    if (newValue <= value) {
-      return fraction;
-    }
-  }
-
-  return 0.125; // Return 0 if no fraction rounds down
-};
-
-// this function is used to snap both resource sliders in unison when one is changed
-export const closestMultiplier = (
-  min: number,
-  max: number,
-  value: number
-): number => {
-  const fractions = [0.5, 0.25, 0.125];
-  let closestFraction = 0.125;
-  for (const fraction of fractions) {
-    const newValue = fraction * (max - min) + min;
-    if (
-      Math.abs(newValue - value) <
-      Math.abs(closestFraction * (max - min) + min - value)
-    ) {
-      closestFraction = fraction;
-    }
-  }
-
-  return closestFraction;
-};

+ 33 - 1
dashboard/src/lib/hooks/useDatastore.ts

@@ -21,7 +21,13 @@ type DatastoreHook = {
 };
 type CreateDatastoreInput = {
   name: string;
-  type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS";
+  type:
+    | "RDS"
+    | "ELASTICACHE"
+    | "MANAGED-POSTGRES"
+    | "MANAGED-REDIS"
+    | "NEON"
+    | "UPSTASH";
   engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
   values: object;
 };
@@ -134,6 +140,32 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
         };
       }
     )
+    .with(
+      { config: { type: "neon" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+          },
+        },
+        type: "NEON",
+        engine: "POSTGRES",
+      })
+    )
+    .with(
+      { config: { type: "upstash" } },
+      (values): CreateDatastoreInput => ({
+        name: values.name,
+        values: {
+          config: {
+            name: values.name,
+          },
+        },
+        type: "UPSTASH",
+        engine: "REDIS",
+      })
+    )
     .exhaustive();
 };
 

+ 10 - 73
dashboard/src/lib/hooks/useMetronome.ts → dashboard/src/lib/hooks/useLago.ts

@@ -2,18 +2,16 @@ import { useContext } from "react";
 import { useQuery } from "@tanstack/react-query";
 
 import {
-  CostValidator,
   CreditGrantsValidator,
   InvoiceValidator,
   PlanValidator,
   ReferralDetailsValidator,
   UsageValidator,
-  type CostList,
   type CreditGrants,
   type InvoiceList,
   type Plan,
   type ReferralDetails,
-  type UsageList,
+  type Usage,
 } from "lib/billing/types";
 
 import api from "shared/api";
@@ -32,11 +30,7 @@ type TGetInvoices = {
 };
 
 type TGetUsage = {
-  usage: UsageList | null;
-};
-
-type TGetCosts = {
-  costs: CostList | null;
+  usageList: Usage[] | null;
 };
 
 type TGetReferralDetails = {
@@ -100,7 +94,6 @@ export const useCustomerPlan = (): TGetPlan => {
           {},
           { project_id: currentProject.id }
         );
-
         const plan = PlanValidator.parse(res.data);
         return plan;
       } catch (error) {
@@ -115,16 +108,15 @@ export const useCustomerPlan = (): TGetPlan => {
 };
 
 export const useCustomerUsage = (
-  startingOn: Date | null,
-  endingBefore: Date | null,
-  windowSize: string
+  previousPeriods: number,
+  currentPeriod: boolean
 ): TGetUsage => {
   const { currentProject } = useContext(Context);
 
   // Fetch customer usage
   const usageReq = useQuery(
-    ["listCustomerUsage", currentProject?.id],
-    async (): Promise<UsageList | null> => {
+    ["listCustomerUsage", currentProject?.id, previousPeriods, currentPeriod],
+    async (): Promise<Usage[] | null> => {
       if (!currentProject?.metronome_enabled) {
         return null;
       }
@@ -133,17 +125,12 @@ export const useCustomerUsage = (
         return null;
       }
 
-      if (startingOn === null || endingBefore === null) {
-        return null;
-      }
-
       try {
         const res = await api.getCustomerUsage(
           "<token>",
           {
-            starting_on: startingOn.toISOString(),
-            ending_before: endingBefore.toISOString(),
-            window_size: windowSize,
+            previous_periods: previousPeriods,
+            current_period: currentPeriod,
           },
           {
             project_id: currentProject?.id,
@@ -158,55 +145,7 @@ export const useCustomerUsage = (
   );
 
   return {
-    usage: usageReq.data ?? null,
-  };
-};
-
-export const useCustomerCosts = (
-  startingOn: Date | null,
-  endingBefore: Date | null,
-  limit: number
-): TGetCosts => {
-  const { currentProject } = useContext(Context);
-
-  // Fetch customer costs
-  const usageReq = useQuery(
-    ["listCustomerCosts", currentProject?.id],
-    async (): Promise<CostList | null> => {
-      if (!currentProject?.metronome_enabled) {
-        return null;
-      }
-
-      if (!currentProject?.id || currentProject.id === -1) {
-        return null;
-      }
-
-      if (startingOn === null || endingBefore === null) {
-        return null;
-      }
-
-      try {
-        const res = await api.getCustomerCosts(
-          "<token>",
-          {},
-          {
-            project_id: currentProject?.id,
-            starting_on: startingOn.toISOString(),
-            ending_before: endingBefore.toISOString(),
-            limit,
-          }
-        );
-
-        const costs = CostValidator.array().parse(res.data);
-        return costs;
-      } catch (error) {
-        return null;
-      }
-    }
-  );
-
-  return {
-    costs: usageReq.data ?? null,
+    usageList: usageReq.data ?? null,
   };
 };
 
@@ -263,9 +202,7 @@ export const useCustomerInvoices = (): TGetInvoices => {
       try {
         const res = await api.getCustomerInvoices(
           "<token>",
-          {
-            status: "paid",
-          },
+          {},
           { project_id: currentProject.id }
         );
 

+ 41 - 0
dashboard/src/lib/hooks/useNeon.ts

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+import {
+  neonIntegrationValidator,
+  type ClientNeonIntegration,
+} from "lib/neon/types";
+
+import api from "shared/api";
+
+type TUseNeon = {
+  getNeonIntegrations: ({
+    projectId,
+  }: {
+    projectId: number;
+  }) => Promise<ClientNeonIntegration[]>;
+};
+export const useNeon = (): TUseNeon => {
+  const getNeonIntegrations = async ({
+    projectId,
+  }: {
+    projectId: number;
+  }): Promise<ClientNeonIntegration[]> => {
+    const response = await api.getNeonIntegrations(
+      "<token>",
+      {},
+      {
+        projectId,
+      }
+    );
+
+    const results = await z
+      .object({ integrations: z.array(neonIntegrationValidator) })
+      .parseAsync(response.data);
+
+    return results.integrations;
+  };
+
+  return {
+    getNeonIntegrations,
+  };
+};

+ 0 - 35
dashboard/src/lib/hooks/useResizeObserver.ts

@@ -1,35 +0,0 @@
-import { useLayoutEffect, useRef } from "react";
-
-/*
- *
- * useResizeObserver takes in a callback function and returns a ref
- * that can be attached to a DOM element. The callback function will
- * be called whenever the DOM element is resized.
- *
- */
-function useResizeObserver<T extends HTMLElement>(
-  callback: (target: T) => void
-) {
-  const ref = useRef<T>(null);
-
-  useLayoutEffect(() => {
-    const element = ref?.current;
-
-    if (!element) {
-      return;
-    }
-
-    const observer = new ResizeObserver(() => {
-      callback(element);
-    });
-
-    observer.observe(element);
-    return () => {
-      observer.disconnect();
-    };
-  }, [callback, ref]);
-
-  return ref;
-}
-
-export default useResizeObserver;

+ 87 - 0
dashboard/src/lib/hooks/useTemplateEnvs.ts

@@ -0,0 +1,87 @@
+import { useContext, useMemo } from "react";
+import { Addon, PorterApp } from "@porter-dev/api-contracts";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+
+import { clientAddonFromProto } from "lib/addons";
+import { type Environment } from "lib/environments/types";
+import { clientAppFromProto } from "lib/porter-apps";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type TUseTemplateEnvs = {
+  environments: Environment[];
+  status: "error" | "success" | "loading";
+};
+
+const listTemplateEnvsResValidator = z.object({
+  name: z.string(),
+  base64_apps: z.string().array().default([]),
+  base64_addons: z.string().array().default([]),
+});
+
+export const useTemplateEnvs = (): TUseTemplateEnvs => {
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const { data: encodedEnvironments = [], status } = useQuery(
+    ["listTemplateEnvironments", currentProject?.id, currentCluster?.id],
+    async () => {
+      if (!currentProject || !currentCluster) {
+        return [];
+      }
+
+      const res = await api.listTemplateEnvironments(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+        }
+      );
+
+      const data = await z
+        .object({
+          environment_templates: z
+            .array(listTemplateEnvsResValidator)
+            .default([]),
+        })
+        .parseAsync(res.data);
+      return data.environment_templates;
+    },
+    {
+      enabled: !!currentProject && !!currentCluster,
+    }
+  );
+
+  const environments = useMemo(() => {
+    return encodedEnvironments.map((env) => {
+      const apps = env.base64_apps.map((a) =>
+        clientAppFromProto({
+          proto: PorterApp.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+          overrides: null,
+        })
+      );
+      const addons = env.base64_addons.map((a) =>
+        clientAddonFromProto({
+          addon: Addon.fromJsonString(atob(a), {
+            ignoreUnknownFields: true,
+          }),
+        })
+      );
+
+      return {
+        name: env.name,
+        apps,
+        addons,
+      };
+    });
+  }, [encodedEnvironments]);
+
+  return {
+    environments,
+    status,
+  };
+};

+ 41 - 0
dashboard/src/lib/hooks/useUpstash.ts

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+import {
+  upstashIntegrationValidator,
+  type ClientUpstashIntegration,
+} from "lib/upstash/types";
+
+import api from "shared/api";
+
+type TUseUpstash = {
+  getUpstashIntegrations: ({
+    projectId,
+  }: {
+    projectId: number;
+  }) => Promise<ClientUpstashIntegration[]>;
+};
+export const useUpstash = (): TUseUpstash => {
+  const getUpstashIntegrations = async ({
+    projectId,
+  }: {
+    projectId: number;
+  }): Promise<ClientUpstashIntegration[]> => {
+    const response = await api.getUpstashIntegrations(
+      "<token>",
+      {},
+      {
+        projectId,
+      }
+    );
+
+    const results = await z
+      .object({ integrations: z.array(upstashIntegrationValidator) })
+      .parseAsync(response.data);
+
+    return results.integrations;
+  };
+
+  return {
+    getUpstashIntegrations,
+  };
+};

+ 6 - 0
dashboard/src/lib/neon/types.ts

@@ -0,0 +1,6 @@
+import { z } from "zod";
+
+export const neonIntegrationValidator = z.object({
+  created_at: z.string(),
+});
+export type ClientNeonIntegration = z.infer<typeof neonIntegrationValidator>;

+ 4 - 1
dashboard/src/lib/porter-apps/build.ts

@@ -1,6 +1,7 @@
-import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 
+import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
+
 // buildValidator is used to validate inputs for build setting fields
 export const buildValidator = z.discriminatedUnion("method", [
   z.object({
@@ -8,11 +9,13 @@ export const buildValidator = z.discriminatedUnion("method", [
     context: z.string().min(1).default("./").catch("./"),
     buildpacks: z.array(buildpackSchema).default([]),
     builder: z.string(),
+    repo: z.string().optional(),
   }),
   z.object({
     method: z.literal("docker"),
     context: z.string().min(1).default("./").catch("./"),
     dockerfile: z.string().min(1).default("./Dockerfile").catch("./Dockerfile"),
+    repo: z.string().optional(),
   }),
 ]);
 export type BuildOptions = z.infer<typeof buildValidator>;

+ 38 - 2
dashboard/src/lib/porter-apps/index.ts

@@ -193,6 +193,36 @@ export const porterAppFormValidator = basePorterAppFormValidator
   );
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
+export const APP_CREATE_FORM_DEFAULTS = {
+  app: {
+    name: {
+      value: "",
+      readOnly: false,
+    },
+    build: {
+      method: "pack" as const,
+      context: "./",
+      builder: "",
+      buildpacks: [],
+    },
+    env: [],
+    efsStorage: {
+      enabled: false,
+    },
+  },
+  source: {
+    git_repo_name: "",
+    git_branch: "",
+    porter_yaml_path: "",
+  },
+  deletions: {
+    serviceNames: [],
+    envGroupNames: [],
+    predeploy: [],
+    initialDeploy: [],
+  },
+};
+
 // serviceOverrides is used to generate the services overrides for an app from porter.yaml
 // this method is only called when a porter.yaml is present and has services defined
 export function serviceOverrides({
@@ -350,6 +380,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           context: b.context,
           buildpacks: b.buildpacks.map((b) => b.buildpack),
           builder: b.builder,
+          repo: b.repo,
         })
     )
     .with(
@@ -359,6 +390,7 @@ const clientBuildToProto = (build: BuildOptions): Build => {
           method: "docker",
           context: b.context,
           dockerfile: b.dockerfile,
+          repo: b.repo,
         })
     )
     .exhaustive();
@@ -479,11 +511,13 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         context: z.string(),
         buildpacks: z.array(z.string()).default([]),
         builder: z.string(),
+        repo: z.string().optional(),
       }),
       z.object({
         method: z.literal("docker"),
         context: z.string(),
         dockerfile: z.string(),
+        repo: z.string().optional(),
       }),
     ])
     .safeParse(proto);
@@ -504,6 +538,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
           buildpack: b,
         })),
         builder: b.builder,
+        repo: b.repo,
       })
     )
     .with({ method: "docker" }, (b) =>
@@ -511,6 +546,7 @@ const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
         method: b.method,
         context: b.context,
         dockerfile: b.dockerfile,
+        repo: b.repo,
       })
     )
     .exhaustive();
@@ -612,8 +648,8 @@ export function clientAppFromProto({
       value: proto.name,
     },
     services,
-    predeploy: predeployList.length ? predeployList : undefined,
-    initialDeploy: initialDeployList.length ? initialDeployList : undefined,
+    predeploy: predeployList,
+    initialDeploy: initialDeployList,
     env: parsedEnv,
     envGroups: proto.envGroups.map((eg) => ({
       name: eg.name,

+ 8 - 0
dashboard/src/lib/upstash/types.ts

@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const upstashIntegrationValidator = z.object({
+  created_at: z.string(),
+});
+export type ClientUpstashIntegration = z.infer<
+  typeof upstashIntegrationValidator
+>;

+ 19 - 8
dashboard/src/main/home/Home.tsx

@@ -19,7 +19,7 @@ import Link from "components/porter/Link";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { useCustomerPlan } from "lib/hooks/useMetronome";
+import { useCustomerPlan } from "lib/hooks/useLago";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";
@@ -65,6 +65,7 @@ import CreateClusterForm from "./infrastructure-dashboard/forms/CreateClusterFor
 import Integrations from "./integrations/Integrations";
 import LaunchWrapper from "./launch/LaunchWrapper";
 import ModalHandler from "./ModalHandler";
+import BillingModal from "./modals/BillingModal";
 import Navbar from "./navbar/Navbar";
 import { NewProjectFC } from "./new-project/NewProject";
 import Onboarding from "./onboarding/Onboarding";
@@ -400,13 +401,14 @@ const Home: React.FC<Props> = (props) => {
       <DeploymentTargetProvider>
         <StyledHome
           padTop={
-            !currentProject?.sandbox_enabled &&
-            showCardBanner &&
-            currentProject?.billing_enabled &&
-            currentProject?.metronome_enabled &&
-            !trialExpired &&
-            plan &&
-            true
+            (!currentProject?.sandbox_enabled &&
+              showCardBanner &&
+              currentProject?.billing_enabled &&
+              currentProject?.metronome_enabled &&
+              !trialExpired &&
+              plan &&
+              true) ||
+            currentProject?.freeze_enabled
           }
         >
           {!currentProject?.sandbox_enabled &&
@@ -433,6 +435,14 @@ const Home: React.FC<Props> = (props) => {
                 )}
               </>
             )}
+          {currentProject?.freeze_enabled && (
+            <GlobalBanner>
+              <i className="material-icons-round">warning</i>
+              This project has been disabled due to recurring issues with the
+              connected payment method. Please contact support@porter.run to
+              reenable this project.
+            </GlobalBanner>
+          )}
           {showBillingModal && (
             <BillingModal
               back={() => {
@@ -716,6 +726,7 @@ export default withRouter(withAuth(Home));
 const GlobalBanner = styled.div`
   width: 100vw;
   z-index: 999;
+  padding: 20px;
   position: fixed;
   top: 0;
   color: #fefefe;

+ 0 - 301
dashboard/src/main/home/WelcomeForm.tsx

@@ -1,301 +0,0 @@
-import React, { useContext, useState } from "react";
-import styled from "styled-components";
-import { CSSTransition } from "react-transition-group";
-import api from "shared/api";
-
-import { Context } from "shared/Context";
-
-type Props = {
-  closeForm: () => void;
-};
-
-type StateType = {
-  active: boolean;
-};
-
-const WelcomeForm: React.FunctionComponent<Props> = ({}) => {
-  const context = useContext(Context);
-  const [active, setActive] = useState(true);
-  const [isCompany, setIsCompany] = useState(true);
-  const [name, setName] = useState("");
-  const [company, setCompany] = useState("");
-  const [role, setRole] = useState("unspecified");
-
-  const submitForm = () => {
-    api
-      .postWelcome(
-        "<token>",
-        {
-          email: context.user && context.user.email,
-          name,
-          isCompany,
-          company,
-          role,
-        },
-        {}
-      )
-      .then(() => {
-        localStorage.setItem("welcomed", "true");
-        setActive(false);
-      })
-      .catch((err) => console.log(err));
-  };
-
-  const renderContents = () => {
-    return (
-      <FadeWrapper>
-        <Title>Welcome to Porter</Title>
-        <Subtitle>Just a few things before getting started.</Subtitle>
-        <SubtitleAlt>
-          <Num>1</Num> What is your name? *
-        </SubtitleAlt>
-        <Input
-          placeholder="John Doe"
-          value={name}
-          onChange={(e: any) => setName(e.target.value)}
-        />
-        <SubtitleAlt>
-          <Num>2</Num> What is your company website? *
-        </SubtitleAlt>
-        <Input
-          placeholder="ex: https://porter.run"
-          value={company}
-          onChange={(e: any) => setCompany(e.target.value)}
-        />
-        <SubtitleAlt>
-          <Num>3</Num> What is your role? *
-        </SubtitleAlt>
-        <RadioButton
-          onClick={() => setRole("founder")}
-          selected={role === "founder"}
-        >
-          <i className="material-icons-round">
-            {role === "founder" ? "check_box" : "check_box_outline_blank"}
-          </i>{" "}
-          Founder
-        </RadioButton>
-        <RadioButton
-          onClick={() => setRole("developer")}
-          selected={role === "developer"}
-        >
-          <i className="material-icons-round">
-            {role === "developer" ? "check_box" : "check_box_outline_blank"}
-          </i>{" "}
-          Developer
-        </RadioButton>
-        <RadioButton
-          onClick={() => setRole("devops")}
-          selected={role === "devops"}
-        >
-          <i className="material-icons-round">
-            {role === "devops" ? "check_box" : "check_box_outline_blank"}
-          </i>{" "}
-          DevOps
-        </RadioButton>
-
-        <Submit
-          isDisabled={!company || role === "unspecified"}
-          onClick={() => company && role !== "unspecified" && submitForm()}
-        >
-          <i className="material-icons-round">check</i> Done
-        </Submit>
-      </FadeWrapper>
-    );
-  };
-
-  return (
-    <CSSTransition
-      in={active}
-      timeout={500}
-      classNames="alert"
-      unmountOnExit
-      onEnter={() => setActive(true)}
-      onExited={() => setActive(false)}
-    >
-      <StyledWelcomeForm>
-        <div>
-          {renderContents()}
-          <br />
-          <br />
-        </div>
-      </StyledWelcomeForm>
-    </CSSTransition>
-  );
-};
-
-export default WelcomeForm;
-
-const Circle = styled.div`
-  width: 13px;
-  height: 13px;
-  border-radius: 20px;
-  background: #ffffff11;
-  margin-right: 12px;
-  border: 1px solid #aaaabb;
-`;
-
-const FadeWrapper = styled.div`
-  background: #202227;
-  opacity: 0;
-  animation: fadeIn 0.7s 0s;
-  animation-fill-mode: forwards;
-`;
-
-const Num = styled.div`
-  display: flex;
-  align-items: center;
-  margin-right: 15px;
-  justify-content: center;
-  width: 30px;
-  height: 30px;
-  border: 1px solid #ffffff;
-`;
-
-const Option = styled.div`
-  width: 500px;
-  max-width: 80vw;
-  height: 50px;
-  background: #ffffff22;
-  display: flex;
-  align-items: center;
-  margin-top: 15px;
-  border: 1px solid #aaaabb;
-  border-radius: 5px;
-  padding-left: 15px;
-  cursor: pointer;
-  :hover {
-    background: #ffffff44;
-  }
-
-  > i {
-    font-size: 20px;
-    margin-right: 12px;
-    color: #aaaabb;
-  }
-
-  opacity: 0;
-  animation: slideIn 0.7s 1.3s;
-  animation-fill-mode: forwards;
-
-  @keyframes slideIn {
-    from {
-      opacity: 0;
-      transform: translateX(-30px);
-    }
-    to {
-      opacity: 1;
-      transform: translateX(0);
-    }
-  }
-`;
-
-const Submit = styled(Option)<{ isDisabled: boolean }>`
-  border: 0;
-  opacity: 0;
-  animation: fadeIn 0.7s 0.5s;
-  animation-fill-mode: forwards;
-  margin-top: 35px;
-  cursor: ${(props) => (props.isDisabled ? "not-allowed" : "pointer")};
-  background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
-  :hover {
-    filter: ${(props) => (props.isDisabled ? "" : "brightness(130%)")};
-    background: ${(props) => (props.isDisabled ? "#aaaabb" : "#616FEEcc")};
-  }
-
-  > i {
-    color: #ffffff;
-  }
-`;
-
-const RadioButton = styled(Option)<{ selected: boolean }>`
-  opacity: 0;
-  background: ${(props) => (props.selected ? "#ffffff44" : "#ffffff22")};
-  animation: fadeIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-
-  > div {
-    background: ${(props) => (props.selected ? "#ffffff44" : "")};
-  }
-`;
-
-const Input = styled.input`
-  width: 500px;
-  max-width: 80vw;
-  height: 50px;
-  background: #ffffff22;
-  font-size: 18px;
-  display: flex;
-  align-items: center;
-  margin-top: 0px;
-  color: #ffffff;
-  border: 1px solid #aaaabb;
-  border-radius: 5px;
-  padding-left: 15px;
-  margin-bottom: 40px;
-
-  opacity: 0;
-  animation: fadeIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-`;
-
-const Subtitle = styled.div<{ delay?: string }>`
-  margin: 20px 0 30px;
-  color: #aaaabb;
-
-  opacity: 0;
-  animation: fadeIn 0.5s ${(props) => props.delay || "0.2s"};
-  animation-fill-mode: forwards;
-`;
-
-const SubtitleAlt = styled(Subtitle)`
-  margin: -5px 0 30px;
-  color: white;
-  display: flex;
-  align-items: center;
-  animation: fadeIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-`;
-
-const Title = styled.div`
-  color: white;
-
-  font-size: 26px;
-  margin-bottom: 5px;
-  display: flex;
-  align-items: center;
-
-  opacity: 0;
-  animation: fadeIn 0.5s 0.2s;
-  animation-fill-mode: forwards;
-
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const StyledWelcomeForm = styled.div`
-  position: fixed;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 1;
-  background: #202227;
-
-  &.alert-exit {
-    opacity: 1;
-  }
-  &.alert-exit-active {
-    opacity: 0;
-    transform: translateY(-100px);
-    transition: opacity 500ms, transform 1000ms;
-  }
-`;

+ 3 - 0
dashboard/src/main/home/add-on-dashboard/AddonHeader.tsx

@@ -20,6 +20,9 @@ const AddonHeader: React.FC = () => {
       .with({ type: "metabase" }, (config) => {
         return config.customDomain || config.porterDomain;
       })
+      .with({ type: "quivr" }, (config) => {
+        return config.customDomain || config.porterDomain;
+      })
       .otherwise(() => "");
   }, [addon]);
 

+ 13 - 31
dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx

@@ -9,10 +9,6 @@ import Banner from "components/porter/Banner";
 import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { type ClientAddon } from "lib/addons";
-import {
-  DEFAULT_ADDON_TAB,
-  SUPPORTED_ADDON_TEMPLATES,
-} from "lib/addons/template";
 
 import { Context } from "shared/Context";
 
@@ -37,38 +33,24 @@ const AddonTabs: React.FC<Props> = ({ tabParam }) => {
     reset(addon);
   }, [addon]);
 
-  const addonTemplate = useMemo(() => {
-    return SUPPORTED_ADDON_TEMPLATES.find(
-      (template) => template.type === addon.config.type
-    );
-  }, [addon]);
-
   const tabs = useMemo(() => {
-    if (addonTemplate) {
-      return addonTemplate.tabs
-        .filter(
-          (t) =>
-            !t.isOnlyForPorterOperators ||
-            (t.isOnlyForPorterOperators && user.isPorterUser)
-        )
-        .map((tab) => ({
-          label: tab.displayName,
-          value: tab.name,
-        }));
-    }
-    return [
-      {
-        label: DEFAULT_ADDON_TAB.displayName,
-        value: DEFAULT_ADDON_TAB.name,
-      },
-    ];
-  }, [addonTemplate]);
+    return addon.template.tabs
+      .filter(
+        (t) =>
+          !t.isOnlyForPorterOperators ||
+          (t.isOnlyForPorterOperators && user.isPorterUser)
+      )
+      .map((tab) => ({
+        label: tab.displayName,
+        value: tab.name,
+      }));
+  }, [addon.template]);
 
   const currentTab = useMemo(() => {
     if (tabParam && tabs.some((tab) => tab.value === tabParam)) {
       return tabParam;
     }
-    return tabs[0].value;
+    return tabs.length ? tabs[0].value : "";
   }, [tabParam, tabs]);
 
   return (
@@ -96,7 +78,7 @@ const AddonTabs: React.FC<Props> = ({ tabParam }) => {
         }}
       />
       <Spacer y={1} />
-      {addonTemplate?.tabs
+      {addon.template.tabs
         .filter(
           (t) =>
             !t.isOnlyForPorterOperators ||

+ 2 - 0
dashboard/src/main/home/add-on-dashboard/common/Configuration.tsx

@@ -8,6 +8,7 @@ import DatadogForm from "../datadog/DatadogForm";
 import MetabaseForm from "../metabase/MetabaseForm";
 import MezmoForm from "../mezmo/MezmoForm";
 import NewRelicForm from "../newrelic/NewRelicForm";
+import QuivrForm from "../quivr/QuivrForm";
 import TailscaleForm from "../tailscale/TailscaleForm";
 
 type Props = {
@@ -22,6 +23,7 @@ const Configuration: React.FC<Props> = ({ type }) => {
     .with("newrelic", () => <NewRelicForm />)
     .with("tailscale", () => <TailscaleForm />)
     .with("deepgram", () => <DeepgramForm />)
+    .with("quivr", () => <QuivrForm />)
     .otherwise(() => null);
 };
 

+ 208 - 0
dashboard/src/main/home/add-on-dashboard/quivr/QuivrForm.tsx

@@ -0,0 +1,208 @@
+import React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import styled from "styled-components";
+
+import CopyToClipboard from "components/CopyToClipboard";
+import Checkbox from "components/porter/Checkbox";
+import CollapsibleContainer from "components/porter/CollapsibleContainer";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useClusterContext } from "main/home/infrastructure-dashboard/ClusterContextProvider";
+import { type ClientAddon } from "lib/addons";
+
+import { stringifiedDNSRecordType } from "utils/ip";
+import copy from "assets/copy-left.svg";
+
+import AddonSaveButton from "../AddonSaveButton";
+
+const QuivrForm: React.FC = () => {
+  const { cluster } = useClusterContext();
+
+  const {
+    register,
+    formState: { errors },
+    control,
+    watch,
+  } = useFormContext<ClientAddon>();
+  const watchExposedToExternalTraffic = watch(
+    "config.exposedToExternalTraffic",
+    false
+  );
+
+  return (
+    <div>
+      <Text size={16}>Quivr configuration</Text>
+      <Spacer y={0.5} />
+      <Controller
+        name={"config.exposedToExternalTraffic"}
+        control={control}
+        render={({ field: { value, onChange } }) => (
+          <Checkbox
+            checked={value}
+            toggleChecked={() => {
+              onChange(!value);
+            }}
+          >
+            <Text>Expose to external traffic</Text>
+          </Checkbox>
+        )}
+      />
+      <CollapsibleContainer isOpened={watchExposedToExternalTraffic}>
+        <Spacer y={0.5} />
+        <Text>Custom domain</Text>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          Add an optional custom domain to access Quivr. If you do not provide a
+          custom domain, Porter will provision a domain for you.
+        </Text>
+        {cluster.ingress_ip !== "" && (
+          <>
+            <Spacer y={0.5} />
+            <div style={{ width: "100%" }}>
+              <Text color="helper">
+                To configure a custom domain, you must add{" "}
+                {stringifiedDNSRecordType(cluster.ingress_ip)} pointing to the
+                following Ingress IP for your cluster:{" "}
+              </Text>
+            </div>
+            <Spacer y={0.5} />
+            <IdContainer>
+              <Code>{cluster.ingress_ip}</Code>
+              <CopyContainer>
+                <CopyToClipboard text={cluster.ingress_ip}>
+                  <CopyIcon src={copy} alt="copy" />
+                </CopyToClipboard>
+              </CopyContainer>
+            </IdContainer>
+            <Spacer y={0.5} />
+          </>
+        )}
+        <ControlledInput
+          type="text"
+          width="300px"
+          {...register("config.customDomain")}
+          placeholder="api.quivr.my-domain.com"
+          error={errors.config?.customDomain?.message}
+        />
+      </CollapsibleContainer>
+      <Spacer y={1} />
+      <Text>Quivr Domain</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="text"
+        width="300px"
+        {...register("config.quivrDomain")}
+        placeholder="https://chat.quivr.com"
+        error={errors.config?.quivrDomain?.message}
+      />
+      <Spacer y={1} />
+      <Text>OpenAI API Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.openAiApiKey")}
+        placeholder="*****"
+        error={errors.config?.openAiApiKey?.message}
+      />
+      <Spacer y={1} />
+      <Text>Supabase URL</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.supabaseUrl")}
+        placeholder="https://*******.supabase.co"
+        error={errors.config?.supabaseUrl?.message}
+      />
+      <Spacer y={1} />
+      <Text>Supabase Service Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.supabaseServiceKey")}
+        placeholder="*****"
+        error={errors.config?.supabaseServiceKey?.message}
+      />
+      <Spacer y={1} />
+      <Text>PostgreSQL Database URL</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.pgDatabaseUrl")}
+        placeholder="postgres://postgres:postgres@my-pg-host.com:5432/quivr"
+        error={errors.config?.pgDatabaseUrl?.message}
+      />
+      <Spacer y={1} />
+      <Text>JWT Secret Token</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.jwtSecretKey")}
+        placeholder="uper-secret-jwt-token-with-at-least-32-characters-long"
+        error={errors.config?.jwtSecretKey?.message}
+      />
+      <Spacer y={1} />
+      <Text>Anthropic API Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.anthropicApiKey")}
+        placeholder="*****"
+        error={errors.config?.anthropicApiKey?.message}
+      />
+      <Spacer y={1} />
+      <Text>Cohere API Key</Text>
+      <Spacer y={0.5} />
+      <ControlledInput
+        type="password"
+        width="300px"
+        {...register("config.cohereApiKey")}
+        placeholder="*****"
+        error={errors.config?.cohereApiKey?.message}
+      />
+      <Spacer y={1} />
+      <AddonSaveButton />
+    </div>
+  );
+};
+
+export default QuivrForm;
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const IdContainer = styled.div`
+  background: #26292e;
+  border-radius: 5px;
+  padding: 10px;
+  display: flex;
+  width: 100%;
+  border-radius: 5px;
+  border: 1px solid ${({ theme }) => theme.border};
+  align-items: center;
+  user-select: text;
+`;
+
+const CopyContainer = styled.div`
+  display: flex;
+  align-items: center;
+  margin-left: auto;
+`;
+
+const CopyIcon = styled.img`
+  cursor: pointer;
+  margin-left: 5px;
+  margin-right: 5px;
+  width: 15px;
+  height: 15px;
+  :hover {
+    opacity: 0.8;
+  }
+`;

+ 1 - 1
dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx

@@ -45,7 +45,7 @@ const TailscaleOverview: React.FC = () => {
 
       const parsed = await z
         .object({
-          services: z.array(tailscaleServiceValidator),
+          services: z.array(tailscaleServiceValidator).optional().default([]),
         })
         .parseAsync(res.data);
 

+ 21 - 2
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -94,7 +94,7 @@ const AppHeader: React.FC = () => {
         <ImageTagContainer hoverable={false}>
           <TagContainer>
             <StatusDot color="#FFA500" />
-            <Code>Awaiting Build</Code>
+            <Code>Awaiting deploy</Code>
           </TagContainer>
         </ImageTagContainer>
       );
@@ -184,7 +184,7 @@ const AppHeader: React.FC = () => {
                       }}
                     />
                     {deploymentTarget.is_preview
-                      ? deploymentTarget.namespace
+                      ? deploymentTarget.name
                       : s.git_branch}
                   </BranchTag>
                 </TagWrapper>
@@ -199,6 +199,25 @@ const AppHeader: React.FC = () => {
                 <Text size={13} color="helper">
                   {s.image.repository}
                 </Text>
+                {deploymentTarget.is_preview && (
+                  <>
+                    <Spacer inline x={1} />
+                    <TagWrapper preview>
+                      Preview
+                      <BranchTag preview>
+                        <PullRequestIcon
+                          styles={{
+                            height: "14px",
+                            opacity: "0.65",
+                            marginRight: "5px",
+                            fill: "",
+                          }}
+                        />
+                        {deploymentTarget.name}
+                      </BranchTag>
+                    </TagWrapper>
+                  </>
+                )}
               </>
             ))
             .otherwise(() => null)}

+ 0 - 108
dashboard/src/main/home/app-dashboard/app-view/tabs/preview-environments/PreviewEnvironmentSettings.tsx

@@ -1,108 +0,0 @@
-import Button from "components/porter/Button";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import React from "react";
-import { Link } from "react-router-dom";
-import { useLatestRevision } from "../../LatestRevisionContext";
-import { useQuery } from "@tanstack/react-query";
-import api from "shared/api";
-import { useGithubWorkflow } from "lib/hooks/useGithubWorkflow";
-import styled from "styled-components";
-import healthy from "assets/status-healthy.png";
-import Icon from "components/porter/Icon";
-import { z } from "zod";
-import { PorterApp } from "@porter-dev/api-contracts";
-
-type Props = {};
-
-const PreviewEnvironmentSettings: React.FC<Props> = ({}) => {
-  const { porterApp, clusterId, projectId } = useLatestRevision();
-
-  const { data: templateExists, status } = useQuery(
-    ["getAppTemplate", projectId, clusterId, porterApp.name],
-    async () => {
-      try {
-        const res = await api.getAppTemplate(
-          "<token>",
-          {},
-          {
-            project_id: projectId,
-            cluster_id: clusterId,
-            porter_app_name: porterApp.name,
-          }
-        );
-
-        const data = await z
-          .object({
-            template_b64_app_proto: z.string(),
-            app_env: z.object({
-              variables: z.record(z.string()).default({}),
-              secret_variables: z.record(z.string()).default({}),
-            }),
-          })
-          .parseAsync(res.data);
-
-        const template = PorterApp.fromJsonString(
-          atob(data.template_b64_app_proto),
-          {
-            ignoreUnknownFields: true,
-          }
-        );
-
-        return {
-          template,
-          env: data.app_env,
-        };
-      } catch (err) {
-        return null;
-      }
-    }
-  );
-
-  const { githubWorkflowFilename, isLoading } = useGithubWorkflow({
-    porterApp,
-    fileNames: [`porter_preview_${porterApp.name}.yml`],
-  });
-
-  if (status === "loading" || isLoading) {
-    return null;
-  }
-
-  return (
-    <>
-      {templateExists && githubWorkflowFilename ? (
-        <EnabledContainer>
-          <Text size={16}>Preview Environments Enabled</Text>
-          <Icon src={healthy} />
-        </EnabledContainer>
-      ) : (
-        <Text size={16}>
-          Enable preview environments for "{porterApp.name}"
-        </Text>
-      )}
-      <Spacer y={0.5} />
-      <Text color="helper">
-        {templateExists && githubWorkflowFilename
-          ? "Preview environments are enabled for this app"
-          : "Setup your app to automatically create preview environments for each pull request."}
-      </Text>
-      <Spacer y={0.5} />
-      <Link to={`/preview-environments/configure?app_name=${porterApp.name}`}>
-        <Button type="button">
-          {templateExists && githubWorkflowFilename
-            ? "Update Settings"
-            : "Enable"}
-        </Button>
-      </Link>
-      <Spacer y={1} />
-    </>
-  );
-};
-
-export default PreviewEnvironmentSettings;
-
-const EnabledContainer = styled.div`
-  display: flex;
-  align-items: center;
-  column-gap: 0.75rem;
-`;

+ 12 - 2
dashboard/src/main/home/app-dashboard/apps/AppGrid.tsx

@@ -134,7 +134,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   {/** TODO: make the status icon dynamic */}
                   {/* <StatusIcon src={healthy} /> */}
-                  <AppSource source={source} />
+                  <AppSource
+                    source={{
+                      from: "porter_apps",
+                      details: source,
+                    }}
+                  />
                   {currentProject?.managed_deployment_targets_enabled &&
                     !currentDeploymentTarget?.is_preview && (
                       <Container row>
@@ -188,7 +193,12 @@ const AppGrid: React.FC<AppGridProps> = ({
                   </Container>
                   <Spacer height="15px" />
                   <Container row>
-                    <AppSource source={source} />
+                    <AppSource
+                      source={{
+                        from: "porter_apps",
+                        details: source,
+                      }}
+                    />
                     <Spacer inline x={1} />
                     <SmallIcon opacity="0.4" src={time} />
                     <Text size={13} color="#ffffff44">

+ 43 - 6
dashboard/src/main/home/app-dashboard/apps/AppMeta.tsx

@@ -4,6 +4,7 @@ import styled from "styled-components";
 import Container from "components/porter/Container";
 import Icon from "components/porter/Icon";
 import Text from "components/porter/Text";
+import { type ClientPorterApp } from "lib/porter-apps";
 
 import box from "assets/box.png";
 import git_scm from "assets/git-scm.svg";
@@ -26,13 +27,49 @@ type IconProps = {
 };
 
 type SourceProps = {
-  source: AppRevisionWithSource["source"];
+  source:
+    | {
+        from: "porter_apps";
+        details: AppRevisionWithSource["source"];
+      }
+    | {
+        from: "app_contract";
+        details: ClientPorterApp;
+      };
 };
 
 export const AppSource: React.FC<SourceProps> = ({ source }) => {
+  if (source.from === "app_contract") {
+    const build = source.details.build;
+    const repoFullName = build.repo
+      ? new URL(build.repo).pathname.substring(1)
+      : "";
+    return (
+      <>
+        {build.repo ? (
+          <Container row>
+            <SmallIcon opacity="0.6" src={github} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {repoFullName.endsWith(".git")
+                ? repoFullName.slice(0, -4)
+                : repoFullName}
+            </Text>
+          </Container>
+        ) : (
+          <Container row>
+            <SmallIcon opacity="0.6" height="18px" src={box} />
+            <Text truncate={true} size={13} color="#ffffff44">
+              {source.details.name.value}
+            </Text>
+          </Container>
+        )}
+      </>
+    );
+  }
+
   return (
     <>
-      {source.image_repo_uri ? (
+      {source.details.image_repo_uri ? (
         <Container row>
           <SmallIcon
             opacity="0.7"
@@ -40,21 +77,21 @@ export const AppSource: React.FC<SourceProps> = ({ source }) => {
             src="https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png"
           />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.image_repo_uri}
+            {source.details.image_repo_uri}
           </Text>
         </Container>
-      ) : source.repo_name ? (
+      ) : source.details.repo_name ? (
         <Container row>
           <SmallIcon opacity="0.6" src={github} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.repo_name}
+            {source.details.repo_name}
           </Text>
         </Container>
       ) : (
         <Container row>
           <SmallIcon src={git_scm} />
           <Text truncate={true} size={13} color="#ffffff44">
-            {source.name}
+            {source.details.name}
           </Text>
         </Container>
       )}

+ 3 - 2
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -34,7 +34,7 @@ import {
   useDeploymentTargetList,
   type DeploymentTarget,
 } from "lib/hooks/useDeploymentTarget";
-import { useCustomerPlan } from "lib/hooks/useMetronome";
+import { useCustomerPlan } from "lib/hooks/useLago";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 
 import api from "shared/api";
@@ -346,7 +346,7 @@ const Apps: React.FC = () => {
                 }}
               >
                 <div>
-                  {currentDeploymentTarget?.namespace ?? "Preview Apps"}
+                  {currentDeploymentTarget?.namespace ?? "Preview Environments"}
                 </div>
                 <Badge>Preview</Badge>
               </div>
@@ -486,6 +486,7 @@ const Apps: React.FC = () => {
         />
       )}
       {!currentProject?.sandbox_enabled &&
+        currentProject?.billing_enabled &&
         trialExpired &&
         !hasPaymentEnabled &&
         showBillingModal && (

+ 6 - 1
dashboard/src/main/home/app-dashboard/apps/SelectableAppList.tsx

@@ -49,7 +49,12 @@ const SelectableAppList: React.FC<AppListProps> = ({ appListItems }) => {
               </Container>
               <Spacer height="15px" />
               <Container row>
-                <AppSource source={ali.app.source} />
+                <AppSource
+                  source={{
+                    from: "porter_apps",
+                    details: ali.app.source,
+                  }}
+                />
                 <Spacer inline x={1} />
               </Container>
             </>

+ 0 - 137
dashboard/src/main/home/app-dashboard/build-settings/AdvancedBuildSettings.tsx

@@ -1,137 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
-import Input from "components/porter/Input";
-import AnimateHeight from "react-animate-height";
-import Select from "components/porter/Select";
-import { BuildMethod, PorterApp } from "../types/porterApp";
-import BuildpackSettings from "./buildpacks/BuildpackSettings";
-import _ from "lodash";
-
-interface AdvancedBuildSettingsProps {
-  porterApp: PorterApp;
-  updatePorterApp: (attrs: Partial<PorterApp>) => void;
-  autoDetectBuildpacks: boolean;
-  buildView: BuildMethod;
-  setBuildView: (buildView: BuildMethod) => void;
-}
-
-const AdvancedBuildSettings: React.FC<AdvancedBuildSettingsProps> = ({
-  porterApp,
-  updatePorterApp,
-  autoDetectBuildpacks,
-  buildView,
-  setBuildView,
-}) => {
-  const [showSettings, setShowSettings] = useState<boolean>(false);
-
-  return (
-    <>
-      <StyledAdvancedBuildSettings
-        showSettings={showSettings}
-        isCurrent={true}
-        onClick={() => {
-          setShowSettings(!showSettings);
-        }}
-      >
-        {buildView == "docker" ? (
-          <AdvancedBuildTitle>
-            <i className="material-icons dropdown">arrow_drop_down</i>
-            Configure Dockerfile settings
-          </AdvancedBuildTitle>
-        ) : (
-          <AdvancedBuildTitle>
-            <i className="material-icons dropdown">arrow_drop_down</i>
-            Configure buildpack settings
-          </AdvancedBuildTitle>
-        )}
-      </StyledAdvancedBuildSettings>
-
-      <AnimateHeight height={showSettings ? "auto" : 0} duration={1000}>
-        <StyledSourceBox>
-          <Select
-            value={buildView}
-            width="300px"
-            options={[
-              { value: "docker", label: "Docker" },
-              { value: "buildpacks", label: "Buildpacks" },
-            ]}
-            setValue={(option: string) => setBuildView(option as BuildMethod)}
-            label="Build method"
-          />
-          {buildView === "docker"
-            ?
-            <>
-              <Spacer y={0.5} />
-              <Text color="helper">Dockerfile path (absolute path)</Text>
-              <Spacer y={0.5} />
-              <Input
-                placeholder="ex: ./Dockerfile"
-                value={porterApp.dockerfile}
-                width="300px"
-                setValue={(val: string) => updatePorterApp({ dockerfile: val })}
-              />
-              <Spacer y={0.5} />
-            </>
-            : <BuildpackSettings
-              porterApp={porterApp}
-              updatePorterApp={updatePorterApp}
-              autoDetectBuildpacks={autoDetectBuildpacks}
-            />}
-        </StyledSourceBox>
-      </AnimateHeight>
-    </>
-  );
-};
-
-export default AdvancedBuildSettings;
-
-const StyledAdvancedBuildSettings = styled.div`
-  color: ${({ showSettings }) => (showSettings ? "white" : "#aaaabb")};
-  background: ${({ theme }) => theme.fg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: 1px solid #7a7b80;
-    color: white;
-  }
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  border-radius: 5px;
-  height: 40px;
-  font-size: 13px;
-  width: 100%;
-  padding-left: 10px;
-  cursor: pointer;
-  border-bottom-left-radius: ${({ showSettings }) => showSettings && "0px"};
-  border-bottom-right-radius: ${({ showSettings }) => showSettings && "0px"};
-
-  .dropdown {
-    margin-right: 8px;
-    font-size: 20px;
-    cursor: pointer;
-    border-radius: 20px;
-    transform: ${(props: { showSettings: boolean; isCurrent: boolean }) =>
-    props.showSettings ? "" : "rotate(-90deg)"};
-  }
-`;
-
-const AdvancedBuildTitle = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const StyledSourceBox = styled.div`
-  width: 100%;
-  color: #ffffff;
-  padding: 25px 35px 25px;
-  position: relative;
-  font-size: 13px;
-  border-radius: 5px;
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-  border-top: 0px;
-  border-top-left-radius: 0px;
-  border-top-right-radius: 0px;
-`;

+ 0 - 195
dashboard/src/main/home/app-dashboard/build-settings/BuildSettingsTab.tsx

@@ -1,195 +0,0 @@
-import React, {
-  useContext,
-  useState,
-} from "react";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
-import { CreateUpdatePorterAppOptions } from "shared/types";
-import { Context } from "shared/Context";
-
-import api from "shared/api";
-import { AxiosError } from "axios";
-import Button from "components/porter/Button";
-import Checkbox from "components/porter/Checkbox";
-import SharedBuildSettings from "./SharedBuildSettings";
-import { BuildMethod, PorterApp } from "../types/porterApp";
-import _ from "lodash";
-
-type Props = {
-  porterApp: PorterApp;
-  setTempPorterApp: (app: PorterApp) => void;
-  updatePorterApp: (options: Partial<CreateUpdatePorterAppOptions>) => Promise<void>;
-  clearStatus: () => void;
-  buildView: BuildMethod;
-  setBuildView: (buildView: BuildMethod) => void;
-};
-
-const BuildSettingsTab: React.FC<Props> = ({
-  porterApp,
-  setTempPorterApp,
-  clearStatus,
-  updatePorterApp,
-  buildView,
-  setBuildView,
-}) => {
-  const { setCurrentError, currentCluster, currentProject } = useContext(Context);
-  const [redeployOnSave, setRedeployOnSave] = useState(true);
-  const [runningWorkflowURL, setRunningWorkflowURL] = useState("");
-
-  const [buttonStatus, setButtonStatus] = useState<
-    "loading" | "success" | string
-  >("");
-
-  const triggerWorkflow = async () => {
-    try {
-      if (currentProject == null || currentCluster == null) {
-        return;
-      }
-
-      const res = await api.reRunGHWorkflow(
-        "",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-          git_installation_id: porterApp.git_repo_id,
-          owner: porterApp.repo_name?.split("/")[0],
-          name: porterApp.repo_name?.split("/")[1],
-          branch: porterApp.git_branch,
-          filename: "porter_stack_" + porterApp.name + ".yml",
-        }
-      );
-      if (res.data != null) {
-        window.open(res.data, "_blank", "noreferrer")
-      }
-    } catch (error) {
-      if (!error?.response) {
-        throw error;
-      }
-
-      let tmpError: AxiosError = error;
-
-      /**
-       * @smell
-       * Currently the expanded chart is clearing all the state when a chart update is triggered (saveEnvVariables).
-       * Temporary usage of setCurrentError until a context is applied to keep the state of the ReRunError during re renders.
-       */
-
-      if (tmpError.response.status === 400) {
-        // setReRunError({
-        //   title: "No previous run found",
-        //   description:
-        //     "There are no previous runs for this workflow, please trigger manually a run before changing the build settings.",
-        // });
-        setCurrentError(
-          "There are no previous runs for this workflow. Please manually trigger a run before changing build settings."
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 409) {
-        // setReRunError({
-        //   title: "The workflow is still running",
-        //   description:
-        //     'If you want to make more changes, please choose the option "Save" until the workflow finishes.',
-        // });
-
-        if (typeof tmpError.response.data === "string") {
-          setRunningWorkflowURL(tmpError.response.data);
-        }
-        setCurrentError(
-          'The workflow is still running. You can "Save" the current build settings for the next workflow run and view the current status of the workflow here: ' +
-          tmpError.response.data
-        );
-        return;
-      }
-
-      if (tmpError.response.status === 404) {
-        let description = "No action file matching this deployment was found.";
-        if (typeof tmpError.response.data === "string") {
-          const filename = tmpError.response.data;
-          description = description.concat(
-            `Please check that the file "${filename}" exists in your repository.`
-          );
-        }
-        // setReRunError({
-        //   title: "The action doesn't seem to exist",
-        //   description,
-        // });
-
-        setCurrentError(description);
-        return;
-      }
-      throw error;
-    }
-  };
-
-  const saveConfig = async () => {
-    try {
-      await updatePorterApp({});
-    } catch (err) {
-      console.log(err);
-    }
-  };
-
-  const handleSave = async () => {
-    setButtonStatus("loading");
-
-    try {
-      await saveConfig();
-      setButtonStatus("success");
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      console.log(error);
-    }
-  };
-
-  const handleSaveAndReDeploy = async () => {
-    setButtonStatus("loading");
-
-    try {
-      await saveConfig();
-      await triggerWorkflow();
-      setButtonStatus("success");
-      clearStatus();
-    } catch (error) {
-      setButtonStatus("Something went wrong");
-      console.log(error);
-    }
-  };
-  return (
-    <>
-      <SharedBuildSettings
-        porterApp={porterApp}
-        updatePorterApp={(attrs: Partial<PorterApp>) => setTempPorterApp(PorterApp.setAttributes(porterApp, attrs))}
-        setPorterYaml={() => { }}
-        autoDetectionOn={false}
-        canChangeRepo={false}
-        buildView={buildView}
-        setBuildView={setBuildView}
-      />
-      <Spacer y={1} />
-      <Checkbox
-        checked={redeployOnSave}
-        toggleChecked={() => setRedeployOnSave(!redeployOnSave)}
-      >
-        <Text>Re-run build and deploy on save</Text>
-      </Checkbox>
-      <Spacer y={1} />
-      <Button
-        onClick={() => {
-          if (redeployOnSave) {
-            handleSaveAndReDeploy();
-          } else {
-            handleSave();
-          }
-        }}
-        status={buttonStatus}
-      >
-        Save build settings
-      </Button>
-    </>
-  );
-};
-
-export default BuildSettingsTab;

+ 0 - 261
dashboard/src/main/home/app-dashboard/build-settings/DetectDockerfileAndPorterYaml.tsx

@@ -1,261 +0,0 @@
-import React, { useState, useEffect, useContext, useCallback } from "react";
-import styled from "styled-components";
-import Button from "components/porter/Button";
-import api from "shared/api";
-import Error from "components/porter/Error";
-
-import { Context } from "shared/Context";
-import { FileType } from "shared/types";
-
-import Spacer from "components/porter/Spacer";
-import Modal from "components/porter/Modal";
-import Input from "components/porter/Input";
-import Text from "components/porter/Text";
-import Link from "components/porter/Link";
-import { PorterApp } from "../types/porterApp";
-
-type PropsType = {
-  setPorterYaml: (yaml: string, filename: string) => void;
-  porterApp: PorterApp;
-  updatePorterApp: (attrs: Partial<PorterApp>) => void;
-  updateDockerfileFound: () => void;
-  setBuildpackView: () => void;
-};
-
-const DetectDockerfileAndPorterYaml: React.FC<PropsType> = ({
-  setPorterYaml,
-  porterApp,
-  updatePorterApp,
-  updateDockerfileFound,
-  setBuildpackView,
-}) => {
-  const [showModal, setShowModal] = useState(false);
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState(false);
-  const [contents, setContents] = useState<FileType[]>([]);
-  const [buttonStatus, setButtonStatus] = useState<React.ReactNode>("");
-  const [possiblePorterYamlPath, setPossiblePorterYamlPath] = useState<string>("");
-
-  const { currentProject } = useContext(Context);
-  const fetchAndSetPorterYaml = async (fileName: string) => {
-    setButtonStatus("loading");
-    const response = await fetchPorterYamlContent(fileName);
-    if (response == null) {
-      setButtonStatus(<Error message="Unable to detect porter.yaml. Please check your path and try again, or continue without using porter.yaml." />);
-    } else {
-      setPorterYaml(atob(response.data), fileName);
-      setButtonStatus("success");
-    }
-    setShowModal(false);
-  };
-
-  useEffect(() => {
-    const fetchOnRender = async () => {
-      try {
-        const response = await fetchPorterYamlContent("./porter.yaml");
-        setPorterYaml(atob(response.data), "./porter.yaml");
-      } catch (error) {
-        setShowModal(true);
-      }
-    };
-    fetchOnRender();
-  }, []);
-
-  useEffect(() => {
-    updateContents();
-  }, []);
-
-  useEffect(() => {
-    const dockerFileItem = contents.find((item: FileType) =>
-      item.path.includes("Dockerfile")
-    );
-
-    if (dockerFileItem) {
-      const path = dockerFileItem.path.startsWith("./") || dockerFileItem.path.startsWith("/") ? dockerFileItem.path : `./${dockerFileItem.path}`;
-      updatePorterApp({ dockerfile: path });
-      updateDockerfileFound();
-    } else {
-      setBuildpackView();
-    }
-  }, [contents]);
-
-  const renderContentList = () => {
-    contents.map((item: FileType, i: number) => {
-      let splits = item.path.split("/");
-      let fileName = splits[splits.length - 1];
-      if (fileName.includes("Dockerfile")) {
-        return false;
-      }
-    });
-
-    return true;
-  };
-
-  const fetchContents = () => {
-    if (currentProject == null) {
-      return;
-    }
-
-    return api.getBranchContents(
-      "<token>",
-      { dir: porterApp.build_context || "./" },
-      {
-        project_id: currentProject.id,
-        git_repo_id: porterApp.git_repo_id,
-        kind: "github",
-        owner: porterApp.repo_name.split("/")[0],
-        name: porterApp.repo_name.split("/")[1],
-        branch: porterApp.git_branch,
-      }
-    );
-  };
-
-  const fetchPorterYamlContent = async (porterYamlPath: string) => {
-    try {
-      if (currentProject == null) {
-        return;
-      }
-      const res = await api.getPorterYamlContents(
-        "<token>",
-        {
-          path: porterYamlPath,
-        },
-        {
-          project_id: currentProject.id,
-          git_repo_id: porterApp.git_repo_id,
-          kind: "github",
-          owner: porterApp.repo_name.split("/")[0],
-          name: porterApp.repo_name.split("/")[1],
-          branch: porterApp.git_branch,
-        }
-      );
-      return res;
-    } catch (err) {
-      // console.log(err);
-    }
-
-  };
-
-  const updateContents = async () => {
-    try {
-      const res = await fetchContents();
-      let files = [] as FileType[];
-      let folders = [] as FileType[];
-      res.data.map((x: FileType, i: number) => {
-        x.type === "dir" ? folders.push(x) : files.push(x);
-      });
-
-      folders.sort((a: FileType, b: FileType) => {
-        return a.path < b.path ? 1 : 0;
-      });
-      files.sort((a: FileType, b: FileType) => {
-        return a.path < b.path ? 1 : 0;
-      });
-      let contents = folders.concat(files);
-
-      setContents(contents);
-      setLoading(false);
-      setError(false);
-    } catch (err) {
-      console.log(err);
-      setLoading(false);
-      setError(true);
-    }
-  };
-
-  const NoPorterYamlContent = () => (
-    <div>
-      <Text size={16}>No <Code>porter.yaml</Code> detected</Text>
-      <Spacer y={0.5} />
-      <span>
-        <Text color="helper">
-          We were unable to find a <Code>porter.yaml</Code> file in your root directory. We
-          recommend that you add a <Code>porter.yaml</Code> file to your root directory
-          or specify the path here.
-        </Text>
-        <Spacer y={0.5} />
-        <Link
-          to="https://docs.porter.run/deploy/configuration-as-code/overview"
-          target="_blank"
-          hasunderline
-        >
-          Using porter.yaml
-        </Link>
-      </span>
-    </div>
-  );
-  return (
-    <>
-      {showModal && (
-        <Modal closeModal={() => setShowModal(false)}>
-          <NoPorterYamlContent />
-          <Spacer y={0.5} />
-          <Text color="helper">Path to <Code>porter.yaml</Code> from repository root:</Text>
-          <Spacer y={0.5} />
-          <Input
-            disabled={false}
-            placeholder="ex: ./subdirectory/porter.yaml"
-            value={possiblePorterYamlPath}
-            width="100%"
-            setValue={setPossiblePorterYamlPath}
-          />
-          <Spacer y={1} />
-          <div style={{ display: "flex", justifyContent: "space-between" }}>
-            <Button
-              onClick={() => {
-                setShowModal(false);
-                updatePorterApp({ porter_yaml_path: "" });
-              }}
-              loadingText="Submitting..."
-              color="#ffffff11"
-              status={loading ? "loading" : undefined}
-            >
-              Ignore
-            </Button>
-            <Button
-              onClick={() => fetchAndSetPorterYaml(possiblePorterYamlPath)}
-              loadingText="Submitting..."
-              color="#616fee"
-              status={loading ? "loading" : undefined}
-            >
-              Update path
-            </Button>
-          </div>
-        </Modal>
-      )}
-      {renderContentList() && (
-        <>
-          {possiblePorterYamlPath !== "" && (
-            <>
-              <Text color="helper">Porter.yaml path:</Text>
-              <Spacer y={0.5} />
-              <Input
-                disabled={false}
-                placeholder="ex: ./"
-                value={possiblePorterYamlPath}
-                width="100%"
-                onValueChange={setPossiblePorterYamlPath}
-              />
-              <Spacer y={1} />
-              <Button
-                onClick={() => fetchAndSetPorterYaml(possiblePorterYamlPath)}
-                loadingText="Submitting..."
-                status={buttonStatus}
-              >
-                Update Path
-              </Button>
-              <Spacer y={1} />
-            </>
-          )}
-
-        </>
-      )}
-    </>
-  );
-};
-
-export default DetectDockerfileAndPorterYaml;
-
-const Code = styled.span`
-  font-family: monospace;
-`;

+ 0 - 195
dashboard/src/main/home/app-dashboard/build-settings/SharedBuildSettings.tsx

@@ -1,195 +0,0 @@
-import Input from "components/porter/Input";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import React from "react";
-import styled from "styled-components";
-import { BuildMethod, PorterApp } from "../types/porterApp";
-import DetectDockerfileAndPorterYaml from "./DetectDockerfileAndPorterYaml";
-import RepositorySelector from "./RepositorySelector";
-import BranchSelector from "./BranchSelector";
-import AdvancedBuildSettings from "./AdvancedBuildSettings";
-
-type Props = {
-  setPorterYaml: (yaml: string, filename: string) => void;
-  updatePorterApp: (attrs: Partial<PorterApp>) => void;
-  porterApp: PorterApp;
-  autoDetectionOn: boolean;
-  canChangeRepo: boolean;
-  buildView: BuildMethod;
-  setBuildView: (buildView: BuildMethod) => void;
-};
-
-const SharedBuildSettings: React.FC<Props> = ({
-  setPorterYaml,
-  updatePorterApp,
-  porterApp,
-  autoDetectionOn,
-  canChangeRepo,
-  buildView,
-  setBuildView,
-}) => {
-  return (
-    <>
-      <Text size={16}>Build settings</Text>
-      <Spacer y={0.5} />
-      <Text color="helper">Specify your GitHub repository.</Text>
-      <Spacer y={0.5} />
-      {porterApp.repo_name === "" && (
-        <>
-          <ExpandedWrapper>
-            <RepositorySelector
-              readOnly={false}
-              updatePorterApp={updatePorterApp}
-              git_repo_name={porterApp.repo_name}
-            />
-          </ExpandedWrapper>
-          <DarkMatter antiHeight="-4px" />
-          <Spacer y={0.3} />
-        </>
-      )}
-      {porterApp.repo_name !== "" && (
-        <>
-          <Input
-            disabled={true}
-            label="GitHub repository:"
-            width="100%"
-            value={porterApp.repo_name}
-            setValue={() => { }}
-            placeholder=""
-          />
-          {canChangeRepo &&
-            <>
-              <BackButton
-                width="135px"
-                onClick={() => {
-                  updatePorterApp({
-                    repo_name: "",
-                    git_branch: "",
-                    dockerfile: "",
-                    build_context: "./",
-                    porter_yaml_path: "./porter.yaml",
-                  })
-                }}
-              >
-                <i className="material-icons">keyboard_backspace</i>
-                Select repo
-              </BackButton>
-              <Spacer y={0.5} />
-            </>
-          }
-          <Spacer y={0.5} />
-          <Text color="helper">Specify your GitHub branch.</Text>
-          <Spacer y={0.5} />
-          {porterApp.git_branch === "" && (
-            <>
-              <ExpandedWrapper>
-                <BranchSelector
-                  setBranch={(branch: string) => updatePorterApp({ git_branch: branch })}
-                  repo_name={porterApp.repo_name}
-                  git_repo_id={porterApp.git_repo_id}
-                />
-              </ExpandedWrapper>
-            </>
-          )}
-          {porterApp.git_branch !== "" && (
-            <>
-              <Input
-                disabled={true}
-                label="GitHub branch:"
-                type="text"
-                width="100%"
-                value={porterApp.git_branch}
-                setValue={() => { }}
-                placeholder=""
-              />
-              <BackButton
-                width="145px"
-                onClick={() => {
-                  updatePorterApp({
-                    git_branch: "",
-                    dockerfile: "",
-                    build_context: "./",
-                    porter_yaml_path: "./porter.yaml",
-                  })
-                }}
-              >
-                <i className="material-icons">keyboard_backspace</i>
-                Select branch
-              </BackButton>
-              <Spacer y={1} />
-              <Text color="helper">Specify your application root path.</Text>
-              <Spacer y={0.5} />
-              <Input
-                placeholder="ex: ./"
-                value={porterApp.build_context}
-                width="100%"
-                setValue={(val: string) => updatePorterApp({ build_context: val })}
-              />
-              <Spacer y={1} />
-              {/* TODO: refactor everything from the below 'component' into this file */}
-              {autoDetectionOn && (
-                <DetectDockerfileAndPorterYaml
-                  setPorterYaml={setPorterYaml}
-                  porterApp={porterApp}
-                  updatePorterApp={updatePorterApp}
-                  updateDockerfileFound={() => setBuildView("docker")}
-                  setBuildpackView={() => setBuildView("buildpacks")}
-                />
-              )}
-              <AdvancedBuildSettings
-                porterApp={porterApp}
-                updatePorterApp={updatePorterApp}
-                autoDetectBuildpacks={autoDetectionOn}
-                buildView={buildView}
-                setBuildView={setBuildView}
-              />
-            </>
-          )}
-        </>
-      )}
-    </>
-  );
-};
-
-export default SharedBuildSettings;
-
-const DarkMatter = styled.div<{ antiHeight?: string }>`
-  width: 100%;
-  margin-top: ${(props) => props.antiHeight || "-15px"};
-`;
-
-const ExpandedWrapper = styled.div`
-  margin-top: 10px;
-  width: 100%;
-  border-radius: 3px;
-  max-height: 275px;
-`;
-
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 22px;
-  cursor: pointer;
-  font-size: 13px;
-  height: 35px;
-  padding: 5px 13px;
-  margin-bottom: -7px;
-  padding-right: 15px;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  width: ${(props: { width: string }) => props.width};
-  color: white;
-  background: #ffffff11;
-
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 16px;
-    margin-right: 6px;
-  }
-`;
-

+ 0 - 158
dashboard/src/main/home/app-dashboard/build-settings/buildpacks/AddCustomBuildpackComponent.tsx

@@ -1,158 +0,0 @@
-import InputRow from "components/form-components/InputRow";
-import React, { useState } from "react";
-import styled, { keyframes } from "styled-components";
-import { Buildpack } from "../../types/buildpack";
-
-function isValidBuildpack(url: string): boolean {
-  const urnPrefix = "urn:cnb:registry:";
-  if (url.startsWith(urnPrefix)) {
-    return true;
-  }
-  try {
-    new URL(url);
-    return true;
-  } catch (error) {
-    return false;
-  }
-}
-
-const AddCustomBuildpackComponent: React.FC<{
-  onAdd: (buildpack: Buildpack) => void;
-}> = ({ onAdd }) => {
-  const [buildpackUrl, setBuildpackUrl] = useState("");
-  const [error, setError] = useState(false);
-
-  const handleAddCustomBuildpack = () => {
-    if (buildpackUrl === "" || !isValidBuildpack(buildpackUrl)) {
-      setError(true);
-      return;
-    }
-    setBuildpackUrl("");
-    onAdd({
-      buildpack: buildpackUrl,
-      name: buildpackUrl,
-      config: {},
-    });
-  };
-
-  return (
-    <StyledCard marginBottom="0px">
-      <ContentContainer>
-        <EventInformation>
-          <BuildpackInputContainer>
-            GitHub or ZIP URL
-            <BuildpackUrlInput
-              placeholder="https://github.com/custom/buildpack"
-              type="input"
-              value={buildpackUrl}
-              isRequired
-              setValue={(newUrl) => {
-                setError(false);
-                setBuildpackUrl(newUrl as string);
-              }}
-            />
-            <ErrorText hasError={error}>Please enter a valid url</ErrorText>
-          </BuildpackInputContainer>
-        </EventInformation>
-      </ContentContainer>
-      <ActionContainer>
-        <ActionButton onClick={() => handleAddCustomBuildpack()}>
-          <span className="material-icons-outlined">add</span>
-        </ActionButton>
-      </ActionContainer>
-    </StyledCard>
-  );
-};
-
-export default AddCustomBuildpackComponent;
-
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const StyledCard = styled.div<{ marginBottom?: string }>`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #494b4f;
-  background: ${({ theme }) => theme.fg};
-  margin-bottom: ${(props) => props.marginBottom || "30px"};
-  border-radius: 8px;
-  padding: 14px;
-  overflow: hidden;
-  height: 60px;
-  font-size: 13px;
-  animation: ${fadeIn} 0.5s;
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 100%;
-  width: 100%;
-  align-items: center;
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const BuildpackInputContainer = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-  padding-left: 15px;
-`;
-
-const BuildpackUrlInput = styled(InputRow)`
-  width: auto;
-  min-width: 300px;
-  max-width: 600px;
-  margin: unset;
-  margin-left: 10px;
-  display: inline-block;
-`;
-
-const ErrorText = styled.span`
-  color: red;
-  margin-left: 10px;
-  display: ${(props: { hasError: boolean }) =>
-    props.hasError ? "inline-block" : "none"};
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const ActionButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  cursor: pointer;
-  color: #aaaabb;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff44;
-  }
-
-  > span {
-    font-size: 20px;
-  }
-`;

+ 0 - 168
dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackCard.tsx

@@ -1,168 +0,0 @@
-import React, { useMemo } from "react";
-import { DeviconsNameList } from "assets/devicons-name-list";
-import styled, { keyframes } from "styled-components";
-import { Draggable } from "react-beautiful-dnd";
-import { Buildpack } from "main/home/app-dashboard/types/buildpack";
-
-interface Props {
-  buildpack: Buildpack;
-  action: "add" | "remove";
-  onClickFn: (buildpack: string) => void;
-  index: number;
-  draggable: boolean;
-}
-
-const BuildpackCard: React.FC<Props> = ({
-  buildpack,
-  action,
-  onClickFn,
-  index,
-  draggable,
-}) => {
-  const iconClassName = useMemo(() => {
-    if (!buildpack.name) {
-      return "";
-    }
-
-    const splits = buildpack.name.split("/");
-    if (splits.length !== 1) {
-      return "";
-    }
-
-    const devicon = DeviconsNameList.find(
-      (devicon) => splits[0].toLowerCase() === devicon.name
-    );
-    if (!devicon) {
-      return "";
-    }
-    return `devicon-${devicon.name}-plain colored`
-  }, [buildpack.name]);
-
-  const renderedBuildpackName = useMemo(() => {
-    return buildpack.name ?? buildpack.buildpack;
-  }, [buildpack.name]);
-
-  return draggable ? (
-    <Draggable draggableId={renderedBuildpackName} index={index} key={renderedBuildpackName}>
-      {(provided) => (
-        <StyledCard
-          marginBottom="5px"
-          {...provided.draggableProps}
-          {...provided.dragHandleProps}
-          ref={provided.innerRef}
-          key={renderedBuildpackName}
-        >
-          <ContentContainer>
-            {iconClassName && <Icon className={iconClassName} />}
-            <EventInformation>
-              <EventName>{renderedBuildpackName}</EventName>
-            </EventInformation>
-          </ContentContainer>
-          <ActionContainer>
-            <ActionButton onClick={() => onClickFn(buildpack.buildpack)}>
-              <span className="material-icons">
-                {action === "remove" ? "delete" : "add"}
-              </span>
-            </ActionButton>
-          </ActionContainer>
-        </StyledCard>
-      )}
-    </Draggable>
-  ) : (
-    <StyledCard marginBottom="5px" key={renderedBuildpackName}>
-      <ContentContainer>
-        {iconClassName && <Icon className={iconClassName} />}
-        <EventInformation>
-          <EventName>{renderedBuildpackName}</EventName>
-        </EventInformation>
-      </ContentContainer>
-      <ActionContainer>
-        <ActionButton onClick={() => onClickFn(buildpack.buildpack)}>
-          <span className="material-icons">
-            {action === "remove" ? "delete" : "add"}
-          </span>
-        </ActionButton>
-      </ActionContainer>
-    </StyledCard>
-  );
-};
-
-export default BuildpackCard;
-
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const StyledCard = styled.div<{ marginBottom?: string }>`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  border: 1px solid #494b4f;
-  background: ${({ theme }) => theme.fg};
-  margin-bottom: ${(props) => props.marginBottom || "30px"};
-  border-radius: 8px;
-  padding: 14px;
-  overflow: hidden;
-  height: 60px;
-  font-size: 13px;
-  animation: ${fadeIn} 0.5s;
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 100%;
-  width: 100%;
-  align-items: center;
-`;
-
-const Icon = styled.span`
-  font-size: 20px;
-  margin-left: 10px;
-  margin-right: 20px
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const EventName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const ActionButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  cursor: pointer;
-  color: #aaaabb;
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff44;
-  }
-  > span {
-    font-size: 20px;
-  }
-`;

+ 0 - 145
dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackConfigurationModal.tsx

@@ -1,145 +0,0 @@
-import Spacer from 'components/porter/Spacer';
-import Text from 'components/porter/Text';
-import React from 'react';
-import BuildpackList from './BuildpackList';
-import AddCustomBuildpackComponent from './AddCustomBuildpackComponent';
-import Icon from 'components/porter/Icon';
-import Button from 'components/porter/Button';
-import Modal from 'components/porter/Modal';
-import styled from 'styled-components';
-import Select from 'components/porter/Select';
-import stars from "assets/stars-white.svg";
-import { Buildpack } from '../../types/buildpack';
-import { PorterApp } from '../../types/porterApp';
-
-interface Props {
-    closeModal: () => void;
-    selectedStack: string;
-    sortedStackOptions: { value: string; label: string }[];
-    setStackValue: (value: string) => void;
-    selectedBuildpacks: Buildpack[];
-    setSelectedBuildpacks: (buildpacks: Buildpack[]) => void;
-    availableBuildpacks: Buildpack[];
-    setAvailableBuildpacks: (buildpacks: Buildpack[]) => void;
-    porterApp: PorterApp;
-    updatePorterApp: (attrs: Partial<PorterApp>) => void;
-    isDetectingBuildpacks: boolean;
-    detectBuildpacksError: string;
-    handleAddCustomBuildpack: (buildpack: Buildpack) => void;
-    detectAndSetBuildPacks: (detect: boolean) => void;
-}
-const BuildpackConfigurationModal: React.FC<Props> = ({
-    closeModal,
-    selectedStack,
-    sortedStackOptions,
-    setStackValue,
-    selectedBuildpacks,
-    setSelectedBuildpacks,
-    availableBuildpacks,
-    setAvailableBuildpacks,
-    porterApp,
-    updatePorterApp,
-    isDetectingBuildpacks,
-    detectBuildpacksError,
-    handleAddCustomBuildpack,
-    detectAndSetBuildPacks,
-}) => {
-    return (
-        <Modal closeModal={closeModal}>
-            <Text size={16}>Buildpack Configuration</Text>
-            <Spacer y={1} />
-            <Scrollable>
-                <Text>Builder:</Text>
-                {selectedStack === "" &&
-                    <>
-                        <Spacer y={0.5} />
-                        <Text color="helper">
-                            No builder detected. Click 'Detect buildpacks' below to scan your repository for available builders and buildpacks.
-                        </Text>
-                    </>
-                }
-                {selectedStack !== "" &&
-                    <>
-                        <Spacer y={0.5} />
-                        <Select
-                            value={selectedStack}
-                            width="300px"
-                            options={sortedStackOptions}
-                            setValue={setStackValue}
-                        />
-                    </>
-                }
-                <BuildpackList
-                    selectedBuildpacks={selectedBuildpacks}
-                    setSelectedBuildpacks={setSelectedBuildpacks}
-                    availableBuildpacks={availableBuildpacks}
-                    setAvailableBuildpacks={setAvailableBuildpacks}
-                    porterApp={porterApp}
-                    updatePorterApp={updatePorterApp}
-                    showAvailableBuildpacks={true}
-                    isDetectingBuildpacks={isDetectingBuildpacks}
-                    detectBuildpacksError={detectBuildpacksError}
-                    droppableId={"modal"}
-                />
-                <Spacer y={0.5} />
-                <Text>
-                    Custom buildpacks
-                </Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                    You may also add buildpacks by directly providing their GitHub links
-                    or links to ZIP files that contain the buildpack source code.
-                </Text>
-                <Spacer y={1} />
-                <AddCustomBuildpackComponent onAdd={handleAddCustomBuildpack} />
-                <Spacer y={2} />
-            </Scrollable>
-            <Footer>
-                <Shade />
-                <FooterButtons>
-                    <Button onClick={() => detectAndSetBuildPacks(true)}>
-                        <Icon src={stars} height="15px" />
-                        <Spacer inline x={0.5} />
-                        Detect buildpacks
-                    </Button>
-                    <Button onClick={closeModal} width={"75px"}>Close</Button>
-                </FooterButtons>
-            </Footer>
-        </Modal>
-    );
-}
-export default BuildpackConfigurationModal;
-
-const Scrollable = styled.div`
-  overflow-y: auto;
-  padding: 0 25px;
-  width: calc(100% + 50px);
-  margin-left: -25px;
-  max-height: calc(100vh - 300px);
-`;
-
-const FooterButtons = styled.div`
-  display: flex;
-  justify-content: space-between;
-`;
-
-const Footer = styled.div`
-  position: relative;
-  width: calc(100% + 50px);
-  margin-left: -25px;
-  padding: 0 25px;
-  border-bottom-left-radius: 10px;
-  border-bottom-right-radius: 10px;
-  background: ${({ theme }) => theme.fg};
-  margin-bottom: -30px;
-  padding-bottom: 30px;
-`;
-
-const Shade = styled.div`
-  position: absolute;
-  top: -50px;
-  left: 0;
-  height: 50px;
-  width: 100%;
-  background: linear-gradient(to bottom, #00000000, ${({ theme }) => theme.fg});
-`;

+ 0 - 142
dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackList.tsx

@@ -1,142 +0,0 @@
-import React from "react";
-import { PorterApp } from "../../types/porterApp";
-import BuildpackCard from "./BuildpackCard";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import Loading from "components/Loading";
-import Error from "components/porter/Error";
-import { Droppable, DragDropContext } from "react-beautiful-dnd";
-import { Buildpack } from "../../types/buildpack";
-
-interface Props {
-    porterApp: PorterApp,
-    updatePorterApp: (attrs: Partial<PorterApp>) => void,
-    selectedBuildpacks: Buildpack[],
-    availableBuildpacks: Buildpack[],
-    setSelectedBuildpacks: (buildpacks: Buildpack[]) => void,
-    setAvailableBuildpacks: (buildpacks: Buildpack[]) => void,
-    showAvailableBuildpacks: boolean,
-    isDetectingBuildpacks: boolean,
-    detectBuildpacksError: string,
-    droppableId: string,
-}
-const BuildpackList: React.FC<Props> = ({
-    porterApp,
-    updatePorterApp,
-    selectedBuildpacks,
-    availableBuildpacks,
-    setSelectedBuildpacks,
-    setAvailableBuildpacks,
-    showAvailableBuildpacks,
-    isDetectingBuildpacks,
-    detectBuildpacksError,
-    droppableId,
-}) => {
-    const handleRemoveBuildpack = (buildpackToRemove: string) => {
-        if (porterApp.buildpacks.includes(buildpackToRemove)) {
-            updatePorterApp({ buildpacks: porterApp.buildpacks.filter(bp => bp !== buildpackToRemove) });
-            const buildpack = selectedBuildpacks.find(bp => bp.buildpack === buildpackToRemove) as Buildpack;
-            if (buildpack != null) {
-                setAvailableBuildpacks([...availableBuildpacks, buildpack]);
-                setSelectedBuildpacks(selectedBuildpacks.filter(bp => bp.buildpack !== buildpackToRemove));
-            }
-        }
-    };
-
-    const handleAddBuildpack = (buildpackToAdd: string) => {
-        if (porterApp.buildpacks.find((bp) => bp === buildpackToAdd) == null) {
-            updatePorterApp({ buildpacks: [...porterApp.buildpacks, buildpackToAdd] });
-            const buildpack = availableBuildpacks.find((bp) => bp.buildpack === buildpackToAdd);
-            if (buildpack != null) {
-                setSelectedBuildpacks([...selectedBuildpacks, buildpack]);
-                setAvailableBuildpacks(availableBuildpacks.filter((bp) => bp.buildpack !== buildpackToAdd));
-            }
-        }
-    };
-
-    const onDragEnd = (result: any) => {
-        if (!result.destination) {
-            return;
-        }
-        const oldSelected = [...selectedBuildpacks];
-        const [removed] = oldSelected.splice(result.source.index, 1);
-        oldSelected.splice(result.destination.index, 0, removed);
-        setSelectedBuildpacks(oldSelected);
-        updatePorterApp({ buildpacks: oldSelected.map((bp) => bp.buildpack) });
-    };
-
-    const renderAvailableBuildpacks = () => {
-        if (isDetectingBuildpacks) {
-            return (
-                <Loading />
-            )
-        }
-
-        if (detectBuildpacksError) {
-            return (
-                <Error message={detectBuildpacksError} />
-            )
-        }
-
-        if (availableBuildpacks.length > 0) {
-            return availableBuildpacks.map((buildpack, index) => {
-                return (
-                    <BuildpackCard
-                        buildpack={buildpack}
-                        action={"add"}
-                        onClickFn={handleAddBuildpack}
-                        index={index}
-                        draggable={false}
-                    />
-                )
-            })
-        }
-
-        return <Text color="helper">No available buildpacks detected.</Text>
-    }
-
-    return (
-        <DragDropContext onDragEnd={onDragEnd}>
-            {showAvailableBuildpacks &&
-                <>
-                    <Spacer y={0.5} />
-                    <Text>Selected buildpacks:</Text>
-                    <Spacer y={0.5} />
-                </>
-            }
-            {selectedBuildpacks.length !== 0 && <Droppable droppableId={droppableId}>
-                {provided => (
-                    <div
-                        {...provided.droppableProps}
-                        ref={provided.innerRef}
-                    >
-                        {selectedBuildpacks.map((buildpack, index) => (
-                            <BuildpackCard
-                                buildpack={buildpack}
-                                action={"remove"}
-                                onClickFn={handleRemoveBuildpack}
-                                index={index}
-                                draggable={true}
-                                key={index}
-                            />
-                        ))}
-                        {provided.placeholder}
-                    </div>
-                )}
-            </Droppable>}
-            {selectedBuildpacks.length === 0 &&
-                <Text color="helper">No buildpacks selected.</Text>
-            }
-            {showAvailableBuildpacks &&
-                <>
-                    <Spacer y={0.5} />
-                    <Text>Available buildpacks:</Text>
-                    <Spacer y={0.5} />
-                    {renderAvailableBuildpacks()}
-                </>
-            }
-        </DragDropContext>
-    );
-};
-
-export default BuildpackList;

+ 0 - 226
dashboard/src/main/home/app-dashboard/build-settings/buildpacks/BuildpackSettings.tsx

@@ -1,226 +0,0 @@
-import Helper from "components/form-components/Helper";
-import React, { useContext, useEffect, useMemo, useState } from "react";
-import api from "shared/api";
-import { Context } from "shared/Context";
-import styled, { keyframes } from "styled-components";
-import Button from "components/porter/Button";
-import Spacer from "components/porter/Spacer";
-import Error from "components/porter/Error";
-import { PorterApp } from "../../types/porterApp";
-import BuildpackList from "./BuildpackList";
-import {
-  BUILDPACK_TO_NAME,
-  BuildConfig,
-  Buildpack,
-  DEFAULT_BUILDER_NAME,
-  DEFAULT_HEROKU_STACK,
-  DetectedBuildpack
-} from "../../types/buildpack";
-import BuildpackConfigurationModal from "./BuildpackConfigurationModal";
-
-const BuildpackSettings: React.FC<{
-  porterApp: PorterApp;
-  updatePorterApp: (attrs: Partial<PorterApp>) => void;
-  autoDetectBuildpacks: boolean;
-}> = ({
-  porterApp,
-  updatePorterApp,
-  autoDetectBuildpacks,
-}) => {
-    const { currentProject } = useContext(Context);
-
-    const [selectedStack, setSelectedStack] = useState<string>("");
-    const [stackOptions, setStackOptions] = useState<{ label: string; value: string }[]>([]);
-    const [isModalOpen, setIsModalOpen] = useState(false);
-    const [isDetectingBuildpacks, setIsDetectingBuildpacks] = useState(false);
-    const [error, setError] = useState<string>("");
-
-    const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>([]);
-    const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>([]);
-
-    const detectAndSetBuildPacks = async (detect: boolean) => {
-      try {
-        if (currentProject == null) {
-          return;
-        }
-
-        if (!detect) {
-          // in this case, we are not detecting buildpacks, so we just populate based on the DB
-          if (porterApp.builder != null) {
-            setSelectedStack(porterApp.builder);
-            setStackOptions([{ label: porterApp.builder, value: porterApp.builder }]);
-          }
-          if (porterApp.buildpacks != null) {
-            setSelectedBuildpacks(porterApp.buildpacks.map(bp => ({
-              name: BUILDPACK_TO_NAME[bp] ?? bp,
-              buildpack: bp,
-              config: {},
-            })));
-          }
-        } else {
-          if (isDetectingBuildpacks) {
-            return;
-          }
-          setIsDetectingBuildpacks(true);
-          const detectBuildPackRes = await api.detectBuildpack(
-            "<token>",
-            {
-              dir: porterApp.build_context || ".",
-            },
-            {
-              project_id: currentProject.id,
-              git_repo_id: porterApp.git_repo_id,
-              kind: "github",
-              owner: porterApp.repo_name.split("/")[0],
-              name: porterApp.repo_name.split("/")[1],
-              branch: porterApp.git_branch,
-            }
-          );
-
-          const builders = detectBuildPackRes.data as DetectedBuildpack[];
-          if (builders.length === 0) {
-            return;
-          }
-          setStackOptions(builders.flatMap((builder) => {
-            return builder.builders.map((stack) => ({
-              label: `${builder.name} - ${stack}`,
-              value: stack.toLowerCase(),
-            }));
-          }).sort((a, b) => {
-            if (a.label < b.label) {
-              return -1;
-            }
-            if (a.label > b.label) {
-              return 1;
-            }
-            return 0;
-          }));
-
-          const defaultBuilder = builders.find(
-            (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
-          ) ?? builders[0];
-
-          const allBuildpacks = defaultBuilder.others.concat(defaultBuilder.detected);
-
-          let detectedBuilder: string;
-          if (defaultBuilder.builders.length && defaultBuilder.builders.includes(DEFAULT_HEROKU_STACK)) {
-            setSelectedStack(DEFAULT_HEROKU_STACK);
-            detectedBuilder = DEFAULT_HEROKU_STACK;
-          } else {
-            setSelectedStack(defaultBuilder.builders[0]);
-            detectedBuilder = defaultBuilder.builders[0];
-          }
-
-          const newBuildpacks = defaultBuilder.detected.filter(bp => !porterApp.buildpacks.includes(bp.buildpack));
-          if (autoDetectBuildpacks) {
-            updatePorterApp({ builder: detectedBuilder, buildpacks: [...porterApp.buildpacks, ...newBuildpacks.map(bp => bp.buildpack)] });
-            setSelectedBuildpacks(defaultBuilder.detected);
-            setAvailableBuildpacks(defaultBuilder.others);
-            setError("");
-          } else {
-            updatePorterApp({ builder: detectedBuilder });
-            setAvailableBuildpacks(allBuildpacks.filter(bp => !porterApp.buildpacks?.includes(bp.buildpack)));
-          }
-        }
-      } catch (err) {
-        setError(`Unable to detect buildpacks at path: ${porterApp.build_context}. Please make sure your repo, branch, and application root path are all set correctly and attempt to detect again.`);
-      } finally {
-        setIsDetectingBuildpacks(false);
-      }
-    }
-
-    useEffect(() => {
-      detectAndSetBuildPacks(autoDetectBuildpacks);
-    }, [currentProject]);
-
-    const handleAddCustomBuildpack = (buildpack: Buildpack) => {
-      if (porterApp.buildpacks.find((bp) => bp === buildpack.buildpack) == null) {
-        updatePorterApp({ buildpacks: [...porterApp.buildpacks, buildpack.buildpack] });
-        setSelectedBuildpacks([...selectedBuildpacks, buildpack]);
-      }
-    };
-
-    return (
-      <BuildpackConfigurationContainer>
-        {selectedBuildpacks.length > 0 && (
-          <>
-            <Helper>
-              The following buildpacks were automatically detected. You can also
-              manually add, remove, or re-order buildpacks here.
-            </Helper>
-            <BuildpackList
-              selectedBuildpacks={selectedBuildpacks}
-              setSelectedBuildpacks={setSelectedBuildpacks}
-              availableBuildpacks={availableBuildpacks}
-              setAvailableBuildpacks={setAvailableBuildpacks}
-              porterApp={porterApp}
-              updatePorterApp={updatePorterApp}
-              showAvailableBuildpacks={false}
-              isDetectingBuildpacks={isDetectingBuildpacks}
-              detectBuildpacksError={error}
-              droppableId={"non-modal"}
-            />
-          </>
-        )}
-        {autoDetectBuildpacks && error !== "" && (
-          <>
-            <Spacer y={1} />
-            <Error message={error} />
-          </>
-        )}
-        <Spacer y={1} />
-        <Button onClick={() => {
-          setIsModalOpen(true);
-          setError("");
-        }}>
-          <I className="material-icons">add</I> Add / detect buildpacks
-        </Button>
-        {isModalOpen && (
-          <BuildpackConfigurationModal
-            closeModal={() => setIsModalOpen(false)}
-            selectedStack={selectedStack}
-            sortedStackOptions={stackOptions}
-            setStackValue={(option) => {
-              setSelectedStack(option);
-              updatePorterApp({ builder: option });
-            }}
-            selectedBuildpacks={selectedBuildpacks}
-            setSelectedBuildpacks={setSelectedBuildpacks}
-            availableBuildpacks={availableBuildpacks}
-            setAvailableBuildpacks={setAvailableBuildpacks}
-            porterApp={porterApp}
-            updatePorterApp={updatePorterApp}
-            isDetectingBuildpacks={isDetectingBuildpacks}
-            detectBuildpacksError={error}
-            handleAddCustomBuildpack={handleAddCustomBuildpack}
-            detectAndSetBuildPacks={detectAndSetBuildPacks}
-          />
-        )}
-      </BuildpackConfigurationContainer>
-    );
-  };
-
-export default BuildpackSettings;
-
-
-const I = styled.i`
-  color: white;
-  font-size: 14px;
-  display: flex;
-  align-items: center;
-  margin-right: 5px;
-  justify-content: center;
-`;
-
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const BuildpackConfigurationContainer = styled.div`
-  animation: ${fadeIn} 0.75s;
-`;

+ 20 - 44
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -34,6 +34,7 @@ import { useIntercom } from "lib/hooks/useIntercom";
 import { usePorterYaml } from "lib/hooks/usePorterYaml";
 import { checkIfProjectHasPayment } from "lib/hooks/useStripe";
 import {
+  APP_CREATE_FORM_DEFAULTS,
   porterAppFormValidator,
   type PorterAppFormData,
   type SourceOptions,
@@ -139,35 +140,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
   const porterAppFormMethods = useForm<PorterAppFormData>({
     resolver: zodResolver(porterAppFormValidator),
     reValidateMode: "onSubmit",
-    defaultValues: {
-      app: {
-        name: {
-          value: "",
-          readOnly: false,
-        },
-        build: {
-          method: "pack",
-          context: "./",
-          builder: "",
-          buildpacks: [],
-        },
-        env: [],
-        efsStorage: {
-          enabled: false,
-        },
-      },
-      source: {
-        git_repo_name: "",
-        git_branch: "",
-        porter_yaml_path: "",
-      },
-      deletions: {
-        serviceNames: [],
-        envGroupNames: [],
-        predeploy: [],
-        initialDeploy: [],
-      },
-    },
+    defaultValues: APP_CREATE_FORM_DEFAULTS,
   });
   const {
     register,
@@ -437,7 +410,7 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
       let stringifiedJson = "unable to stringify errors";
       try {
         stringifiedJson = JSON.stringify(errors);
-      } catch (e) { }
+      } catch (e) {}
       void updateAppStep({
         step: "stack-launch-failure",
         errorMessage: `Form validation error (visible to user): ${errorMessage}. Stringified JSON errors (invisible to user): ${stringifiedJson}`,
@@ -546,8 +519,8 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                     <Text
                       color={
                         isNameHighlight &&
-                          porterAppFormMethods.getValues("app.name.value")
-                            .length > 0
+                        porterAppFormMethods.getValues("app.name.value")
+                          .length > 0
                           ? "#FFCC00"
                           : "helper"
                       }
@@ -682,8 +655,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
                             }
                           >
                             {detectedServices.count > 0
-                              ? `Detected ${detectedServices.count} service${detectedServices.count > 1 ? "s" : ""
-                              } from porter.yaml.`
+                              ? `Detected ${detectedServices.count} service${
+                                  detectedServices.count > 1 ? "s" : ""
+                                } from porter.yaml.`
                               : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
                           </Text>
                         </AppearingDiv>
@@ -778,16 +752,18 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
           }}
         />
       )}
-      {currentProject?.sandbox_enabled && currentProject?.billing_enabled && !hasPaymentEnabled && (
-        <BillingModal
-          back={() => {
-            history.push("/apps");
-          }}
-          onCreate={async () => {
-            history.push("/apps/new/app");
-          }}
-        />
-      )}
+      {currentProject?.sandbox_enabled &&
+        currentProject?.billing_enabled &&
+        !hasPaymentEnabled && (
+          <BillingModal
+            back={() => {
+              history.push("/apps");
+            }}
+            onCreate={async () => {
+              history.push("/apps/new/app");
+            }}
+          />
+        )}
     </CenterWrapper>
   );
 };

+ 0 - 128
dashboard/src/main/home/app-dashboard/create-app/PorterYamlModal.tsx

@@ -1,128 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-
-import Link from "components/porter/Link";
-import Spacer from "components/porter/Spacer";
-import Modal from "components/porter/Modal";
-import Text from "components/porter/Text";
-import Input from "components/porter/Input";
-import Button from "components/porter/Button";
-import FileSelector from "../validate-apply/build-settings/FileSelector";
-
-type Props = {
-    close: () => void;
-    setPorterYamlPath: (path: string) => void;
-    porterYamlPath: string;
-    projectId: number;
-    repoId: number;
-    repoOwner: string;
-    repoName: string;
-    branch: string;
-}
-
-const PorterYamlModal: React.FC<Props> = ({ 
-    close, 
-    setPorterYamlPath, 
-    porterYamlPath,
-    projectId,
-    repoId,
-    repoOwner,
-    repoName,
-    branch, 
-}) => {
-    const [possiblePorterYamlPath, setPossiblePorterYamlPath] = useState<string>("");
-    const [showModal, setShowModal] = useState<boolean>(true);
-    const [showFileSelector, setShowFileSelector] = useState<boolean>(false);
-
-    return showModal ? (
-        <Modal closeModal={() => { setShowModal(false); }}>
-            <div>
-                <Text size={16}>No <Code>porter.yaml</Code> detected at <Code>{porterYamlPath}</Code></Text>
-                <Spacer y={0.5} />
-                <span>
-                    <Text color="helper">
-                        We were unable to find a <Code>porter.yaml</Code> file in your repository.
-                    </Text>
-                    <Spacer y={0.5} />
-                    <Text color="helper">
-                        Although not required, we
-                        recommend that you add a <Code>porter.yaml</Code> file to the root of your repository,
-                        or you may specify its path here.
-                    </Text>
-                    <Spacer y={0.5} />
-                    <Link
-                        to="https://docs.porter.run/deploy/configuration-as-code/overview"
-                        target="_blank"
-                        hasunderline
-                    >
-                        Using porter.yaml
-                    </Link>
-                </span>
-            </div>
-            <Spacer y={0.5} />
-            <Text color="helper">Path to <Code>porter.yaml</Code> from repository root:</Text>
-            <Spacer y={0.5} />
-            <InputWrapper
-                onClick={(e) => {
-                    e.stopPropagation();
-                    if (!showFileSelector) {
-                        setShowFileSelector(true);
-                        setPossiblePorterYamlPath("");
-                    }
-                }}
-            >
-                <Input
-                    placeholder="ex: ./subdirectory/porter.yaml"
-                    value={possiblePorterYamlPath}
-                    width="100%"
-                    setValue={setPossiblePorterYamlPath}
-                    hideCursor={true}
-                />
-            </InputWrapper>
-            {showFileSelector && 
-                <div>
-                    <FileSelector 
-                        projectId={projectId} 
-                        repoId={repoId} 
-                        repoOwner={repoOwner} 
-                        repoName={repoName} 
-                        branch={branch}
-                        onFileSelect={(path: string) => { setPossiblePorterYamlPath(`./${path}`); }} 
-                        isFileSelectable={(path: string) => path.endsWith(".yaml")}
-                        headerText={"Select your porter.yaml:"}
-                    />
-                </div>
-            }
-            <Spacer y={1} />
-            <div style={{ display: "flex", justifyContent: "space-between" }}>
-                <Button
-                    onClick={close}
-                    color="#ffffff11"
-                >
-                    Ignore
-                </Button>
-                <Button
-                    onClick={() => {
-                        setPorterYamlPath(possiblePorterYamlPath);
-                        setShowModal(false);
-                    }}
-                    disabled={possiblePorterYamlPath === ""}
-                >
-                    Confirm path
-                </Button>
-            </div>
-        </Modal>
-    ) : null;
-};
-
-export default PorterYamlModal;
-
-const Code = styled.span`
-  font-family: monospace;
-`;
-
-const InputWrapper = styled.div`
-    width: 500px;
-    display: flex;
-    justify-content: space-between;
-`;

+ 0 - 224
dashboard/src/main/home/app-dashboard/new-app-flow/GithubConnectModal.tsx

@@ -1,224 +0,0 @@
-import { RouteComponentProps, withRouter } from "react-router";
-import styled from "styled-components";
-import React, { useEffect, useState } from "react";
-
-import Modal from "components/porter/Modal";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
-import ExpandableSection from "components/porter/ExpandableSection";
-import Fieldset from "components/porter/Fieldset";
-import Button from "components/porter/Button";
-
-import api from "shared/api";
-import Error from "components/porter/Error";
-
-import Helper from "components/form-components/Helper";
-import github from "assets/github-white.png";
-
-type Props = RouteComponentProps & {
-  closeModal: () => void;
-  hasClickedDoNotConnect: boolean;
-  handleDoNotConnect: () => void;
-  setAccessError: (error: boolean) => void;
-  setAccessLoading: (loading: boolean) => void;
-  setAccessData: (data: GithubAppAccessData) => void;
-  accessData: GithubAppAccessData;
-  accessError: boolean;
-};
-
-interface GithubAppAccessData {
-  username?: string;
-  accounts?: string[];
-  accessError?: boolean;
-}
-
-const GithubConnectModal: React.FC<Props> = ({
-  closeModal,
-  hasClickedDoNotConnect,
-  handleDoNotConnect,
-  accessError,
-  setAccessError,
-  setAccessLoading,
-  setAccessData,
-  accessData,
-}) => {
-  const [loading, setLoading] = React.useState<boolean>(false);
-  const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
-  const encoded_redirect_uri = encodeURIComponent(url);
-
-  const renderGithubConnect = () => {
-    const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
-    const encoded_redirect_uri = encodeURIComponent(url);
-
-    if (accessError) {
-      return (
-        <>
-          <Text color="helper">To deploy from GitHub, authorize Porter to view your repos.</Text>
-          <ListWrapper>
-            <Helper>
-              No connected repos found.
-              <A href={"/api/integrations/github-app/oauth"}>
-                Authorize Porter to view your repos.
-              </A>
-            </Helper>
-          </ListWrapper>
-          <Spacer y={1} />
-          <Button
-            onClick={handleDoNotConnect}
-            loadingText="Submitting..."
-            color="#ffffff11"
-            status={loading ? "loading" : undefined}
-          >
-            Dismiss
-          </Button>
-        </>
-      );
-    } else if (!accessData.accounts || accessData.accounts?.length == 0) {
-      return (
-        <>
-          <Text color="helper">
-            You are currently authorized as <B>{accessData.username}</B>.
-          </Text>
-          <Spacer y={1} />
-          <ConnectToGithubButton
-            href={`/api/integrations/github-app/install?redirect_uri=${encoded_redirect_uri}`}
-            target="_blank"
-            rel="noopener noreferrer"
-            onClick={closeModal}
-          >
-            <GitHubIcon src={github} />
-            Install the Porter GitHub app
-          </ConnectToGithubButton>
-          <Spacer y={1} />
-          <Button
-            onClick={handleDoNotConnect}
-            loadingText="Submitting..."
-            color="#ffffff11"
-            withBorder
-            status={loading ? "loading" : undefined}
-          >
-            Dismiss
-          </Button>
-        </>
-      );
-    }
-  };
-  useEffect(() => {
-    api
-      .getGithubAccounts("<token>", {}, {})
-      .then(({ data }) => {
-        setAccessData(data);
-        setAccessLoading(false);
-      })
-      .catch(() => {
-        setAccessError(true);
-        setAccessLoading(false);
-      });
-  }, []);
-  return (
-    !hasClickedDoNotConnect &&
-    (accessError ||
-      !accessData.accounts ||
-      accessData.accounts?.length === 0) && (
-      <>
-        <Modal closeModal={closeModal}>
-          <Text size={16}>
-            <GitIcon src={github} />
-            Configure GitHub
-          </Text>
-          <Spacer y={0.5} />
-          {renderGithubConnect()}
-        </Modal>
-      </>
-    )
-  );
-};
-
-export default withRouter(GithubConnectModal);
-
-const B = styled.b`
-  display: inline;
-  color: #ffffff;
-  margin-left: 5px;
-`;
-
-const GitIcon = styled.img`
-  width: 15px;
-  height: 15px;
-  opacity: 0.9;
-  margin-right: 10px;
-  filter: brightness(120%);
-`;
-
-const ListWrapper = styled.div`
-  width: 100%;
-  height: 240px;
-  background: #ffffff11;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  border-radius: 5px;
-  margin-top: 20px;
-  padding: 40px;
-`;
-const A = styled.a`
-  color: #8590ff;
-  text-decoration: underline;
-  margin-left: 5px;
-  cursor: pointer;
-`;
-
-const ConnectToGithubButton = styled.a`
-  width: 240px;
-  justify-content: center;
-  border-radius: 5px;
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-  font-weight: 500;
-  padding: 10px;
-  overflow: hidden;
-  white-space: nowrap;
-  border: 1px solid #494b4f;
-  text-overflow: ellipsis;
-  cursor: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "not-allowed" : "pointer"};
-
-  background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#2E3338"};
-  :hover {
-    background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "#353a3e"};
-  }
-
-  > 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;
-  }
-  &:hover {
-    background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "" : "#353a3e"};
-  }
-
-  &:not([disabled]) {
-    cursor: pointer;
-  }
-`;
-
-const GitHubIcon = styled.img`
-  width: 20px;
-  filter: brightness(150%);
-  margin-right: 10px;
-`;

+ 0 - 97
dashboard/src/main/home/app-dashboard/new-app-flow/SourceSettings.tsx

@@ -1,97 +0,0 @@
-import AnimateHeight from "react-animate-height";
-import React from "react";
-import Spacer from "components/porter/Spacer";
-import styled from "styled-components";
-import { SourceType } from "./SourceSelector";
-import { RouteComponentProps, withRouter } from "react-router";
-import SharedBuildSettings from "../build-settings/SharedBuildSettings";
-import { BuildMethod, PorterApp } from "../types/porterApp";
-import ImageSettings from "../image-settings/ImageSettings";
-
-type Props = RouteComponentProps & {
-  source: SourceType | undefined;
-  imageUrl: string;
-  setImageUrl: (x: string) => void;
-  imageTag: string;
-  setImageTag: (x: string) => void;
-  setPorterYaml: (yaml: string, filename: string) => void;
-  porterApp: PorterApp;
-  setPorterApp: React.Dispatch<React.SetStateAction<PorterApp>>;
-  buildView: BuildMethod;
-  setBuildView: (buildView: BuildMethod) => void;
-  projectId: number;
-  resetImageInfo: () => void;
-};
-
-const SourceSettings: React.FC<Props> = ({
-  source,
-  imageUrl,
-  setImageUrl,
-  imageTag,
-  setImageTag,
-  setPorterYaml,
-  porterApp,
-  setPorterApp,
-  buildView,
-  setBuildView,
-  projectId,
-  resetImageInfo,
-}) => {
-  return (
-    <SourceSettingsContainer>
-      <AnimateHeight height={source ? "auto" : 0}>
-        <Spacer y={1} />
-        {source === "github" ? (
-          <SharedBuildSettings
-            setPorterYaml={setPorterYaml}
-            porterApp={porterApp}
-            updatePorterApp={(attrs: Partial<PorterApp>) => setPorterApp((prev: PorterApp) => PorterApp.setAttributes(prev, attrs))}
-            autoDetectionOn={true}
-            canChangeRepo={true}
-            buildView={buildView}
-            setBuildView={setBuildView}
-          />
-        ) :
-          <ImageSettings
-            projectId={projectId}
-            imageTag={imageTag}
-            setImageTag={setImageTag}
-            imageUri={imageUrl}
-            setImageUri={setImageUrl}
-            resetImageInfo={resetImageInfo}
-          />
-        }
-      </AnimateHeight>
-    </SourceSettingsContainer>
-  );
-};
-
-export default withRouter(SourceSettings);
-
-const SourceSettingsContainer = styled.div``;
-
-const DarkMatter = styled.div<{ antiHeight?: string }>`
-  width: 100%;
-  margin-top: ${(props) => props.antiHeight || "-15px"};
-`;
-
-const Subtitle = styled.div`
-  padding: 11px 0px 16px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-`;
-
-const StyledSourceBox = styled.div`
-  width: 100%;
-  color: #ffffff;
-  padding: 14px 35px 20px;
-  position: relative;
-  font-size: 13px;
-  margin-top: 6px;
-  margin-bottom: 25px;
-  border-radius: 5px;
-  background: ${(props) => props.theme.fg};
-  border: 1px solid #494b4f;
-`;

+ 0 - 144
dashboard/src/main/home/app-dashboard/new-app-flow/schema.tsx

@@ -1,144 +0,0 @@
-import { KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
-import * as z from "zod";
-import { JobService, ReleaseService, Service, WebService, WorkerService } from "./serviceTypes";
-import { overrideObjectValues } from "./utils";
-import _ from "lodash";
-
-const appConfigSchema = z.object({
-    run: z.string().min(1),
-    config: z.any().optional(),
-    type: z.enum(['web', 'worker', 'job']).optional(),
-});
-
-export const AppsSchema = z.record(appConfigSchema);
-
-export const EnvSchema = z.record(z.string());
-
-export const BuildSchema = z.object({
-    method: z.string().refine(value => ["pack", "docker", "registry"].includes(value)),
-    context: z.string().optional(),
-    builder: z.string().optional(),
-    buildpacks: z.array(z.string()).optional(),
-    dockerfile: z.string().optional(),
-    image: z.string().optional()
-}).refine(value => {
-    if (value.method === "pack") {
-        return value.builder != null;
-    }
-    if (value.method === "docker") {
-        return value.dockerfile != null;
-    }
-    if (value.method === "registry") {
-        return value.image != null;
-    }
-    return false;
-},
-    { message: "Invalid build configuration" });
-
-
-export const PorterYamlSchema = z.object({
-    version: z.string().optional(),
-    build: BuildSchema.optional(),
-    env: EnvSchema.optional(),
-    apps: AppsSchema,
-    release: appConfigSchema.optional(),
-});
-
-export const createFinalPorterYaml = (
-    services: Service[],
-    dashboardSetEnvVariables: KeyValueType[],
-    porterJson: PorterJson | undefined,
-    injectPortEnvVariable: boolean = false,
-): PorterJson => {
-    const [apps, port] = createApps(services.filter(Service.isNonRelease), porterJson, injectPortEnvVariable);
-    const env = combineEnv(dashboardSetEnvVariables, porterJson?.env);
-
-    // inject a port env variable if necessary
-    if (port != null) {
-        env.PORT = port;
-    }
-
-    const release = services.find(Service.isRelease);
-
-    return release != null && !_.isEmpty(release.startCommand.value) ? {
-        version: "v1stack",
-        env,
-        apps,
-        release: createRelease(release, porterJson),
-    } : {
-        version: "v1stack",
-        env,
-        apps,
-    };
-};
-
-const combineEnv = (
-    dashboardSetVariables: KeyValueType[],
-    porterYamlSetVariables: Record<string, string> | undefined
-): z.infer<typeof EnvSchema> => {
-    const env: z.infer<typeof EnvSchema> = {};
-    for (const { key, value } of dashboardSetVariables) {
-        env[key] = value;
-    }
-    if (porterYamlSetVariables != null) {
-        for (const [key, value] of Object.entries(porterYamlSetVariables)) {
-            env[key] = value;
-        }
-    }
-    return env;
-};
-
-const createApps = (
-    serviceList: (WorkerService | WebService | JobService)[],
-    porterJson: PorterJson | undefined,
-    injectPortEnvVariable: boolean,
-): [z.infer<typeof AppsSchema>, string | undefined] => {
-    const apps: z.infer<typeof AppsSchema> = {};
-    let port: string | undefined = undefined;
-    for (const service of serviceList) {
-        let config = Service.serialize(service);
-
-        if (
-            porterJson != null &&
-            porterJson.apps != null &&
-            porterJson.apps[service.name] != null &&
-            porterJson.apps[service.name].config != null
-        ) {
-            config = overrideObjectValues(
-                config,
-                porterJson.apps[service.name].config
-            );
-        }
-
-        if (injectPortEnvVariable && service.type === "web") {
-            port = service.port.value;
-        }
-
-        apps[service.name] = {
-            type: service.type,
-            run: service.startCommand.value,
-            config,
-        };
-    }
-
-    return [apps, port];
-};
-
-const createRelease = (release: ReleaseService, porterJson: PorterJson | undefined): z.infer<typeof appConfigSchema> => {
-    let config = Service.serialize(release);
-
-    if (porterJson?.release?.config != null) {
-        config = overrideObjectValues(
-            config,
-            porterJson.release.config
-        );
-    }
-
-    return {
-        type: 'job',
-        run: release.startCommand.value,
-        config,
-    }
-}
-
-export type PorterJson = z.infer<typeof PorterYamlSchema>;

+ 0 - 237
dashboard/src/main/home/app-dashboard/validate-apply/app-settings/ExpandableEnvGroup.tsx

@@ -1,237 +0,0 @@
-import React, { useState } from "react";
-import styled, { keyframes } from "styled-components";
-import { type PopulatedEnvGroup } from "./types";
-import Spacer from "components/porter/Spacer";
-import Icon from "components/porter/Icon";
-
-type Props = {
-  index: number;
-  remove: (index: number) => void;
-  envGroup: PopulatedEnvGroup;
-  icon: JSX.Element;
-};
-
-const ExpandableEnvGroup: React.FC<Props> = ({ index, remove, envGroup, icon }) => {
-  const [isExpanded, setIsExpanded] = useState(false);
-
-  return (
-    <StyledCard>
-      <Flex>
-        {icon}
-        <Spacer inline x={1} />
-        <ContentContainer>
-          <EventInformation>
-            <EventName>{envGroup.name}</EventName>
-          </EventInformation>
-        </ContentContainer>
-        <ActionContainer>
-          <ActionButton type="button" onClick={() => { remove(index); }}>
-            <span className="material-icons">delete</span>
-          </ActionButton>
-          <ActionButton
-            type="button"
-            onClick={() => { setIsExpanded((prev) => !prev); }}
-          >
-            <i className="material-icons">
-              {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
-            </i>
-          </ActionButton>
-        </ActionContainer>
-      </Flex>
-      {isExpanded ? (
-        <>
-          {Object.entries(envGroup.variables ?? {}).map(([key, value], i) => (
-            <InputWrapper key={i}>
-              <KeyInput
-                placeholder="ex: key"
-                width="270px"
-                value={key}
-                disabled
-              />
-              <Spacer x={0.5} inline />
-              <MultiLineInput
-                placeholder="ex: value"
-                width="270px"
-                value={value}
-                disabled
-                rows={value.split("\n").length}
-                spellCheck={false}
-              />
-            </InputWrapper>
-          ))}
-          {Object.entries(envGroup.secret_variables ?? {}).map(
-            ([key, value], i) => (
-              <InputWrapper key={i}>
-                <KeyInput
-                  placeholder="ex: key"
-                  width="270px"
-                  value={key}
-                  disabled
-                />
-                <Spacer x={0.5} inline />
-                <KeyInput
-                  placeholder="ex: value"
-                  width="270px"
-                  value={value}
-                  disabled
-                  type="password"
-                />
-              </InputWrapper>
-            )
-          )}
-        </>
-      ) : null}
-    </StyledCard>
-  );
-};
-
-export default ExpandableEnvGroup;
-
-const fadeIn = keyframes`
-  from {
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-`;
-
-const StyledCard = styled.div`
-  border: 1px solid #ffffff44;
-  background: #ffffff11;
-  border-radius: 8px;
-  padding: 10px 14px;
-  overflow: hidden;
-  font-size: 13px;
-  animation: ${fadeIn} 0.5s;
-`;
-
-const Flex = styled.div`
-  display: flex;
-  height: 25px;
-  align-items: center;
-  justify-content: space-between;
-`;
-
-const ContentContainer = styled.div`
-  display: flex;
-  height: 40px;
-  width: 100%;
-  align-items: center;
-`;
-
-const EventInformation = styled.div`
-  display: flex;
-  flex-direction: column;
-  justify-content: space-around;
-  height: 100%;
-`;
-
-const EventName = styled.div`
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const ActionContainer = styled.div`
-  display: flex;
-  align-items: center;
-  white-space: nowrap;
-  height: 100%;
-`;
-
-const ActionButton = styled.button`
-  position: relative;
-  border: none;
-  background: none;
-  color: white;
-  padding: 5px;
-  width: 30px;
-  height: 30px;
-  margin-left: 5px;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  border-radius: 50%;
-  cursor: pointer;
-  color: #aaaabb;
-  border: 1px solid #ffffff00;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff44;
-  }
-
-  > span {
-    font-size: 20px;
-  }
-`;
-
-const InputWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  margin-top: 5px;
-`;
-
-type InputProps = {
-  disabled?: boolean;
-  width: string;
-  borderColor?: string;
-};
-
-const KeyInput = styled.input<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;
-  width: ${(props) => (props.width ? props.width : "270px")};
-  color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
-  padding: 5px 10px;
-  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;
-  }
-`;

+ 0 - 189
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Old_GPUResources.tsx

@@ -1,189 +0,0 @@
-import React, { useContext, useState } from "react";
-import { Switch } from "@material-ui/core";
-import { Controller, useFormContext } from "react-hook-form";
-import styled from "styled-components";
-
-import Loading from "components/Loading";
-import Container from "components/porter/Container";
-import InputSlider from "components/porter/InputSlider";
-import Link from "components/porter/Link";
-import Spacer from "components/porter/Spacer";
-import Tag from "components/porter/Tag";
-import Text from "components/porter/Text";
-import ProvisionClusterModal from "main/home/sidebar/ProvisionClusterModal";
-import { type PorterAppFormData } from "lib/porter-apps";
-
-import { Context } from "shared/Context";
-import addCircle from "assets/add-circle.png";
-import infra from "assets/cluster.svg";
-
-type Props = {
-  clusterContainsGPUNodes: boolean;
-  maxGPU: number;
-  index: number;
-};
-
-// TODO: delete this file and all references once new infra tab is GA
-const OldGPUResources: React.FC<Props> = ({
-  clusterContainsGPUNodes,
-  maxGPU,
-  index,
-}) => {
-  const { currentCluster } = useContext(Context);
-  const [clusterModalVisible, setClusterModalVisible] =
-    useState<boolean>(false);
-
-  const { control, watch } = useFormContext<PorterAppFormData>();
-  const gpu = watch(`app.services.${index}.gpu.enabled`, {
-    readOnly: false,
-    value: false,
-  });
-  return (
-    <>
-      <Spacer y={1} />
-      <Controller
-        name={`app.services.${index}.gpu`}
-        control={control}
-        render={({ field: { value, onChange } }) => (
-          <>
-            <Container row>
-              <Switch
-                size="small"
-                color="primary"
-                checked={value.enabled.value}
-                disabled={!clusterContainsGPUNodes}
-                onChange={() => {
-                  onChange({
-                    ...value,
-                    enabled: {
-                      ...value.enabled,
-                      value: !value.enabled.value,
-                    },
-                    gpuCoresNvidia: {
-                      ...value.gpuCoresNvidia,
-                      value: value.enabled.value ? 0 : 1,
-                    },
-                  });
-                }}
-                inputProps={{ "aria-label": "controlled" }}
-              />
-              <Spacer inline x={0.5} />
-              <Text>
-                <>
-                  <span>Enable GPU</span>
-                </>
-              </Text>
-
-              {!clusterContainsGPUNodes && (
-                <>
-                  <Spacer inline x={1} />
-                  <Text color="helper">
-                    Your cluster has no GPU nodes available.
-                  </Text>
-                  <Spacer inline x={0.5} />
-                  {currentCluster?.status !== "UPDATING" && (
-                    <Tag>
-                      <Link
-                        onClick={() => {
-                          setClusterModalVisible(true);
-                        }}
-                      >
-                        <TagIcon src={addCircle} />
-                        Add GPU nodes
-                      </Link>
-                    </Tag>
-                  )}
-                </>
-              )}
-            </Container>
-
-            <Spacer y={0.5} />
-            {clusterModalVisible && (
-              <ProvisionClusterModal
-                closeModal={() => {
-                  setClusterModalVisible(false);
-                }}
-                gpuModal={true}
-                gcp={currentCluster?.cloud_provider === "GCP"}
-                azure={currentCluster?.cloud_provider === "Azure"}
-              />
-            )}
-          </>
-        )}
-      />
-      {maxGPU > 1 && gpu.value && (
-        <>
-          <Spacer y={1} />
-          <Controller
-            name={`app.services.${index}.gpu`}
-            control={control}
-            render={({ field: { value, onChange } }) => (
-              <InputSlider
-                label="GPU"
-                unit=""
-                min={0}
-                max={maxGPU}
-                value={(value?.gpuCoresNvidia.value ?? 1).toString()}
-                disabled={value?.gpuCoresNvidia.readOnly}
-                setValue={(e) => {
-                  onChange({
-                    ...value,
-                    gpuCoresNvidia: {
-                      ...value.gpuCoresNvidia,
-                      value: e,
-                    },
-                  });
-                }}
-                disabledTooltip={
-                  "You may only edit this field in your porter.yaml."
-                }
-              />
-            )}
-          />
-        </>
-      )}
-      {currentCluster?.status === "UPDATING" && !clusterContainsGPUNodes && (
-        <CheckItemContainer>
-          <CheckItemTop>
-            <Loading offset="0px" width="20px" height="20px" />
-            <Spacer inline x={1} />
-            <Text>{"Cluster is updating..."}</Text>
-            <Spacer inline x={1} />
-            <Tag>
-              <Link to={`/cluster-dashboard`}>
-                <TagIcon src={infra} />
-                View Status
-              </Link>
-            </Tag>
-          </CheckItemTop>
-        </CheckItemContainer>
-      )}
-    </>
-  );
-};
-
-export default OldGPUResources;
-
-const CheckItemContainer = styled.div`
-  display: flex;
-  flex-direction: column;
-  border: 1px solid ${(props) => props.theme.border};
-  border-radius: 5px;
-  font-size: 13px;
-  width: 100%;
-  margin-bottom: 10px;
-  padding-left: 10px;
-  background: ${(props) => props.theme.clickable.bg};
-`;
-
-const CheckItemTop = styled.div`
-  display: flex;
-  align-items: center;
-  padding: 10px;
-  background: ${(props) => props.theme.clickable.bg};
-`;
-
-const TagIcon = styled.img`
-  height: 12px;
-  margin-right: 3px;
-`;

+ 7 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Resources.tsx

@@ -135,7 +135,7 @@ const Resources: React.FC<ResourcesProps> = ({
         <>
           <Spacer y={1} />
           <Text>
-            Sleep Service
+            Sleep service
             <a
               href="https://docs.porter.run/configure/basic-configuration#sleep-mode"
               target="_blank"
@@ -151,6 +151,7 @@ const Resources: React.FC<ResourcesProps> = ({
             render={({ field: { value, onChange } }) => (
               <Checkbox
                 checked={Boolean(value?.value)}
+                disabled={currentProject?.freeze_enabled}
                 toggleChecked={() => {
                   onChange({
                     ...value,
@@ -158,7 +159,11 @@ const Resources: React.FC<ResourcesProps> = ({
                   });
                 }}
               >
-                <Text color="helper">Pause all instances.</Text>
+                <Text color="helper">
+                  {currentProject?.freeze_enabled
+                    ? "Contact support@porter.run to re-enable your project and unsleep services."
+                    : "Pause all instances."}
+                </Text>
               </Checkbox>
             )}
           />

+ 0 - 181
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/utils.ts

@@ -1,181 +0,0 @@
-export const MIB_TO_GIB = 1024;
-export const MILI_TO_CORE = 1000;
-type InstanceDetails = {
-  vCPU: number;
-  RAM: number;
-  GPU?: number;
-};
-
-type InstanceTypes = Record<string, Record<string, InstanceDetails>>;
-type AzureInstanceTypes = Record<string, InstanceDetails>;
-
-// use values from AWS as base constant, convert to MB
-export const AWS_INSTANCE_LIMITS: InstanceTypes = Object.freeze({
-  t3a: {
-    nano: { vCPU: 2, RAM: 0.5 },
-    micro: { vCPU: 2, RAM: 1 },
-    small: { vCPU: 2, RAM: 2 },
-    medium: { vCPU: 2, RAM: 4 },
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-  },
-  t3: {
-    nano: { vCPU: 2, RAM: 0.5 },
-    micro: { vCPU: 2, RAM: 1 },
-    small: { vCPU: 2, RAM: 2 },
-    medium: { vCPU: 2, RAM: 4 },
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-  },
-  t2: {
-    nano: { vCPU: 1, RAM: 0.5 },
-    micro: { vCPU: 1, RAM: 1 },
-    small: { vCPU: 1, RAM: 2 },
-    medium: { vCPU: 2, RAM: 4 },
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-  },
-  t4g: {
-    nano: { vCPU: 2, RAM: 0.5 },
-    micro: { vCPU: 2, RAM: 1 },
-    small: { vCPU: 2, RAM: 2 },
-    medium: { vCPU: 2, RAM: 4 },
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-  },
-  c6a: {
-    large: { vCPU: 2, RAM: 4 },
-    xlarge: { vCPU: 4, RAM: 8 },
-    "2xlarge": { vCPU: 8, RAM: 16 },
-    "4xlarge": { vCPU: 16, RAM: 32 },
-    "8xlarge": { vCPU: 32, RAM: 64 },
-  },
-  c6i: {
-    large: { vCPU: 2, RAM: 4 },
-    xlarge: { vCPU: 4, RAM: 8 },
-    "2xlarge": { vCPU: 8, RAM: 16 },
-    "4xlarge": { vCPU: 16, RAM: 32 },
-    "8xlarge": { vCPU: 32, RAM: 64 },
-    "12xlarge": { vCPU: 48, RAM: 96 },
-  },
-  g4dn: {
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-    "8xlarge": { vCPU: 32, RAM: 128 },
-  },
-  r6a: {
-    large: { vCPU: 2, RAM: 16 },
-    xlarge: { vCPU: 4, RAM: 32 },
-    "2xlarge": { vCPU: 8, RAM: 64 },
-    "4xlarge": { vCPU: 16, RAM: 128 },
-    "8xlarge": { vCPU: 32, RAM: 256 },
-  },
-  c5: {
-    large: { vCPU: 2, RAM: 4 },
-    xlarge: { vCPU: 4, RAM: 8 },
-    "2xlarge": { vCPU: 8, RAM: 16 },
-    "4xlarge": { vCPU: 16, RAM: 32 },
-  },
-  m5: {
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-  },
-  m5n: {
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-  },
-  m6a: {
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-    "8xlarge": { vCPU: 32, RAM: 128 },
-    "12xlarge": { vCPU: 48, RAM: 192 },
-  },
-  m7i: {
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-    "8xlarge": { vCPU: 32, RAM: 128 },
-    "12xlarge": { vCPU: 48, RAM: 192 },
-  },
-  x2gd: {
-    medium: { vCPU: 1, RAM: 16 },
-    large: { vCPU: 2, RAM: 32 },
-    xlarge: { vCPU: 4, RAM: 64 },
-  },
-  m5n: {
-    large: { vCPU: 2, RAM: 8 },
-    xlarge: { vCPU: 4, RAM: 16 },
-    "2xlarge": { vCPU: 8, RAM: 32 },
-    "4xlarge": { vCPU: 16, RAM: 64 },
-  },
-  // add GCP instance tyoes : TO DO add a dedicated section for GCP
-  e2: {
-    "standard-2": { vCPU: 2, RAM: 8 },
-    "standard-4": { vCPU: 4, RAM: 16 },
-    "standard-8": { vCPU: 8, RAM: 32 },
-    "standard-16": { vCPU: 16, RAM: 64 },
-    "standard-32": { vCPU: 32, RAM: 128 },
-    "standard-64": { vCPU: 64, RAM: 256 },
-  },
-  c3: {
-    "highcpu-4": { vCPU: 4, RAM: 8 },
-    "highcpu-8": { vCPU: 8, RAM: 16 },
-    "highcpu-22": { vCPU: 22, RAM: 44 },
-    "highcpu-44": { vCPU: 44, RAM: 88 },
-    "highmem-4": { vCPU: 4, RAM: 32 },
-    "highmem-8": { vCPU: 8, RAM: 64 },
-    "highmem-22": { vCPU: 22, RAM: 176 },
-    "highmem-44": { vCPU: 44, RAM: 352 },
-    "standard-4": { vCPU: 4, RAM: 16 },
-    "standard-8": { vCPU: 8, RAM: 32 },
-    "standard-22": { vCPU: 22, RAM: 88 },
-    "standard-44": { vCPU: 44, RAM: 176 },
-  },
-  c7g: {
-    large: { vCPU: 2, RAM: 4 },
-    xlarge: { vCPU: 4, RAM: 8 },
-    "2xlarge": { vCPU: 8, RAM: 16 },
-    "4xlarge": { vCPU: 16, RAM: 32 },
-    "8xlarge": { vCPU: 32, RAM: 64 },
-    "12xlarge": { vCPU: 48, RAM: 96 },
-    "16xlarge": { vCPU: 64, RAM: 128 },
-  },
-});
-
-export const GPU_INSTANCE_LIMIT: InstanceTypes = Object.freeze({
-  g4dn: {
-    xlarge: { vCPU: 4, RAM: 16, GPU: 1 },
-    "2xlarge": { vCPU: 8, RAM: 32, GPU: 1 },
-  },
-  p4d: {
-    "24xlarge": { vCPU: 96, RAM: 1152, GPU: 8 },
-  },
-  n1: {
-    "standard-1": { vCPU: 1, RAM: 3.75, GPU: 1 },
-    "standard-2": { vCPU: 2, RAM: 7.5, GPU: 1 },
-    "standard-4": { vCPU: 4, RAM: 15, GPU: 1 },
-    "standard-8": { vCPU: 8, RAM: 30, GPU: 1 },
-    "standard-16": { vCPU: 16, RAM: 60, GPU: 1 },
-    "standard-32": { vCPU: 32, RAM: 120, GPU: 1 },
-    "high-mem-2": { vCPU: 2, RAM: 13, GPU: 1 },
-    "high-mem-4": { vCPU: 4, RAM: 26, GPU: 1 },
-    "high-mem-8": { vCPU: 8, RAM: 52, GPU: 1 },
-    "high-mem-16": { vCPU: 16, RAM: 104, GPU: 1 },
-    "high-mem-32": { vCPU: 32, RAM: 208, GPU: 1 },
-    "high-cpu-8": { vCPU: 2, RAM: 1.8, GPU: 1 },
-    "high-cpu-16": { vCPU: 4, RAM: 3.6, GPU: 1 },
-    "high-cpu-32": { vCPU: 8, RAM: 7.2, GPU: 1 },
-  },
-});

+ 0 - 14
dashboard/src/main/home/cluster-dashboard/ClusterPlaceholderContainer.tsx

@@ -1,14 +0,0 @@
-import React, { useContext } from "react";
-
-import { Context } from "shared/Context";
-import ClusterPlaceholder from "./ClusterPlaceholder";
-
-type PropsType = {};
-
-const ClusterPlaceholderContainer: React.FC<PropsType> = () => {
-  const context = useContext(Context);
-
-  return <ClusterPlaceholder currentCluster={context.currentCluster} />;
-}
-
-export default ClusterPlaceholderContainer;

+ 0 - 408
dashboard/src/main/home/cluster-dashboard/dashboard/Compliance.tsx

@@ -1,408 +0,0 @@
-import React, { useContext, useEffect, useMemo, useState } from "react";
-import type { JsonValue } from "@bufbuild/protobuf";
-import { Cluster, Contract, EKS, EKSLogging } from "@porter-dev/api-contracts";
-import axios from "axios";
-import styled from "styled-components";
-import { match } from "ts-pattern";
-
-import Loading from "components/Loading";
-import Button from "components/porter/Button";
-import Container from "components/porter/Container";
-import Error from "components/porter/Error";
-import Fieldset from "components/porter/Fieldset";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import ToggleRow from "components/porter/ToggleRow";
-import SOC2Checks from "components/SOC2Checks";
-
-import api from "shared/api";
-import { Context } from "shared/Context";
-import { type Soc2Data } from "shared/types";
-import sparkle from "assets/sparkle.svg";
-
-import DonutChart from "./DonutChart";
-
-type Props = {
-  credentialId: string;
-  provisionerError?: string;
-  selectedClusterVersion: JsonValue;
-};
-
-//  Example SOC2 Check NOTE PLEASE ADD FUNC TO createContract and useEffect(() to correctly READ AND WRITE
-//  "Display_Name_Of_SOC2_Check": {
-//   "message": "Main Example Message about the Check",
-//   "link": "example link for more docs ",
-//   "enabled": (false or true if porter does it by default),
-//   "status": ""(Keep blank or ENABLED if porter does it by default)
-//  "enabledField": "text that goes next to the toggle",
-//   "info": " more information",
-//   "locked":(true if unmutable field like KMS),
-//   "disabledTooltip": "display if message is disabled",
-//  "hideToggle": true (if you want to hide the toggle
-// }
-const soc2DataDefault: Soc2Data = {
-  soc2_checks: {
-    "Public SSH Access": {
-      message:
-        "Porter-provisioned instances do not allow remote SSH access. Users are not allowed to invoke commands directly on the host, and all commands are invoked via the EKS Control Plane.",
-      enabled: true,
-      hideToggle: true,
-      status: "ENABLED",
-    },
-    "Cluster Secret Encryption": {
-      message:
-        "Cluster secrets can be encrypted using an AWS KMS Key. Secrets will be encrypted at rest, and encryption cannot be disabled for secrets.",
-      enabled: false,
-      disabledTooltip:
-        "Enable KMS encryption for the cluster to enable SOC 2 compliance.",
-      link: "https://aws.amazon.com/about-aws/whats-new/2020/03/amazon-eks-adds-envelope-encryption-for-secrets-with-aws-kms/",
-      locked: true,
-      status: "",
-    },
-    "Control Plane Log Retention": {
-      message:
-        "EKS Control Plane logs are by default available for a minimal amount of time, typically 1 hour or less. EKS CloudTrail Forwarding automatically sends control plane logs to CloudTrail for longer retention and later inspection.",
-      enabled: false,
-      enabledField: "Retain CloudTrail logs for 365 days",
-      status: "",
-    },
-    "Enhanced Image Vulnerability Scanning": {
-      message:
-        "AWS ECR scans for CVEs from the open-source Clair database on image push. Enhanced scanning provides continuous, automated scans against images as new vulnerabilities appear.",
-      link: "https://docs.aws.amazon.com/AmazonECR/latest/userguide/image-scanning-enhanced.html",
-      enabled: false,
-      info: "",
-      status: "",
-    },
-  },
-};
-
-const DEFAULT_ERROR_MESSAGE =
-  "An error occurred while provisioning your infrastructure. Please try again.";
-
-const Compliance: React.FC<Props> = (props) => {
-  const { currentProject, currentCluster, setShouldRefreshClusters } =
-    useContext(Context);
-
-  const [isClicked, setIsClicked] = useState(false);
-  const [isLoading, setIsLoading] = useState(false);
-  const [soc2Enabled, setSoc2Enabled] = useState(false);
-  const [clusterRegion, setClusterRegion] = useState("");
-  const [errorMessage, setErrorMessage] = useState<string>("");
-  const [errorDetails, setErrorDetails] = useState<string>("");
-  const [soc2Data, setSoc2Data] = useState(soc2DataDefault);
-  const [isReadOnly, setIsReadOnly] = useState(false);
-
-  const applySettings = async (): Promise<void> => {
-    if (!currentCluster || !currentProject || !setShouldRefreshClusters) {
-      return;
-    }
-
-    try {
-      setIsLoading(true);
-      setIsClicked(true);
-      setIsReadOnly(true);
-
-      const contractResults = await api.getContracts(
-        "<token>",
-        { cluster_id: currentCluster.id },
-        { project_id: currentProject.id }
-      );
-
-      if (contractResults.data.length === 0) {
-        setErrorMessage("Unable to retrieve contract results");
-        setErrorDetails("");
-        return;
-      }
-
-      const result = contractResults.data.reduce(
-        (prev: { CreatedAt: string }, current: { CreatedAt: string }) =>
-          Date.parse(current.CreatedAt) > Date.parse(prev.CreatedAt)
-            ? current
-            : prev
-      );
-
-      const contract = createContract(result.base64_contract);
-
-      await api.createContract("<token>", contract, {
-        project_id: currentProject.id,
-      });
-      setShouldRefreshClusters(true);
-
-      setIsClicked(false);
-      setIsLoading(false);
-      setIsReadOnly(false);
-    } catch (err) {
-      let errMessage =
-        "Failed to provision cluster, please contact support@porter.run.";
-      if (axios.isAxiosError(err) && err.response?.data) {
-        errMessage = err.response.data.error.replace("unknown: ", "");
-      }
-
-      // hacky, need to standardize error contract with backend
-      setIsClicked(false);
-      setIsLoading(false);
-      void markStepStarted("provisioning-failed", errMessage);
-
-      // enable edit again only in the case of an error
-      setIsClicked(false);
-      setIsReadOnly(false);
-    } finally {
-      setIsLoading(false);
-    }
-  };
-
-  const createContract = (base64Contract: string): Contract => {
-    //
-    const cloudTrailEnabled =
-      soc2Data.soc2_checks["Control Plane Log Retention"].enabled;
-    const kmsEnabled =
-      soc2Data.soc2_checks["Cluster Secret Encryption"].enabled;
-    const ecrScanningEnabled =
-      soc2Data.soc2_checks["Enhanced Image Vulnerability Scanning"].enabled;
-
-    const contractData = JSON.parse(atob(base64Contract));
-    const latestCluster: Cluster = Cluster.fromJson(contractData.cluster, {
-      ignoreUnknownFields: true,
-    });
-
-    const updatedKindValues = match(latestCluster.kindValues)
-      .with({ case: "eksKind" }, ({ value }) => ({
-        value: new EKS({
-          ...value,
-          enableKmsEncryption: soc2Enabled || kmsEnabled || false,
-          enableEcrScanning:
-            soc2Enabled ||
-            ecrScanningEnabled ||
-            value.enableEcrScanning ||
-            false,
-          logging: new EKSLogging({
-            enableApiServerLogs: soc2Enabled || cloudTrailEnabled || false,
-            enableAuditLogs: soc2Enabled || cloudTrailEnabled || false,
-            enableAuthenticatorLogs: soc2Enabled || cloudTrailEnabled || false,
-            enableCloudwatchLogsToS3: soc2Enabled || cloudTrailEnabled || false,
-            enableControllerManagerLogs:
-              soc2Enabled || cloudTrailEnabled || false,
-            enableSchedulerLogs: soc2Enabled || cloudTrailEnabled || false,
-          }),
-        }),
-        case: "eksKind" as const,
-      }))
-      .with({ case: "gkeKind" }, ({ value }) => ({
-        value,
-        case: "gkeKind" as const,
-      }))
-      .with({ case: "aksKind" }, ({ value }) => ({
-        value,
-        case: "aksKind" as const,
-      }))
-      .with({ case: undefined }, () => ({
-        value: undefined,
-        case: undefined,
-      }))
-      .exhaustive();
-    const cluster = new Cluster({
-      ...latestCluster,
-      kindValues: updatedKindValues,
-    });
-
-    return new Contract({
-      cluster,
-    });
-  };
-
-  const getStatus = (): JSX.Element | string => {
-    if (isLoading) {
-      return <Loading />;
-    }
-    if (isReadOnly && props.provisionerError === "") {
-      return "Provisioning is still in progress...";
-    } else if (errorMessage !== "") {
-      return (
-        <Error
-          message={
-            errorDetails !== ""
-              ? errorMessage + " (" + errorDetails + ")"
-              : errorMessage
-          }
-          ctaText={
-            errorMessage !== DEFAULT_ERROR_MESSAGE
-              ? "Troubleshooting steps"
-              : undefined
-          }
-        />
-      );
-    }
-    return "";
-  };
-
-  const isDisabled = (): boolean | undefined => {
-    return (
-      isUserProvisioning ||
-      isClicked ||
-      (currentCluster && !currentProject?.enable_reprovision)
-    );
-  };
-
-  const markStepStarted = async (
-    step: string,
-    errMessage?: string
-  ): Promise<void> => {
-    try {
-      await api.updateOnboardingStep(
-        "<token>",
-        {
-          step,
-          error_message: errMessage,
-          region: clusterRegion,
-          provider: "aws",
-        },
-        {
-          project_id: currentProject ? currentProject.id : 0,
-        }
-      );
-    } catch (err) {}
-  };
-
-  const isUserProvisioning = useMemo(() => {
-    return isReadOnly && props.provisionerError === "";
-  }, [isReadOnly, props.provisionerError]);
-
-  const determineStatus = (enabled: boolean): string => {
-    if (enabled) {
-      if (currentCluster?.status === "UPDATING") {
-        return "PENDING_ENABLED";
-      } else return "ENABLED";
-    }
-    return "";
-  };
-
-  useEffect(() => {
-    const contract: Contract = Contract.fromJson(props.selectedClusterVersion, {
-      ignoreUnknownFields: true,
-    });
-
-    if (contract.cluster && contract.cluster.kindValues.case === "eksKind") {
-      const eksValues = contract.cluster.kindValues.value;
-      const cloudTrailEnabled =
-        eksValues.logging != null &&
-        eksValues.logging.enableApiServerLogs &&
-        eksValues.logging.enableAuditLogs &&
-        eksValues.logging.enableAuthenticatorLogs &&
-        eksValues.logging.enableControllerManagerLogs;
-
-      setClusterRegion(eksValues.region);
-
-      setSoc2Data((prevSoc2Data) => {
-        return {
-          ...prevSoc2Data,
-          soc2_checks: {
-            ...prevSoc2Data.soc2_checks,
-            "Control Plane Log Retention": {
-              ...prevSoc2Data.soc2_checks["Control Plane Log Retention"],
-              enabled: cloudTrailEnabled,
-              status: determineStatus(cloudTrailEnabled),
-            },
-            "Cluster Secret Encryption": {
-              ...prevSoc2Data.soc2_checks["Cluster Secret Encryption"],
-              enabled: eksValues.enableKmsEncryption,
-              status: determineStatus(eksValues.enableKmsEncryption),
-            },
-            "Enhanced Image Vulnerability Scanning": {
-              ...prevSoc2Data.soc2_checks[
-                "Enhanced Image Vulnerability Scanning"
-              ],
-              enabled: eksValues.enableEcrScanning,
-              status: determineStatus(eksValues.enableEcrScanning),
-            },
-          },
-        };
-      });
-
-      setSoc2Enabled(
-        cloudTrailEnabled &&
-          eksValues.enableKmsEncryption &&
-          eksValues.enableEcrScanning
-      );
-    }
-  }, [props.selectedClusterVersion]);
-
-  useEffect(() => {
-    if (!currentCluster) {
-      return;
-    }
-
-    setIsReadOnly(
-      currentCluster.status === "UPDATING" ||
-        currentCluster.status === "UPDATING_UNAVAILABLE"
-    );
-  }, []);
-
-  return (
-    <StyledCompliance>
-      <Spacer y={1} />
-      <Fieldset>
-        <Container row>
-          <Text size={16}>SOC 2 Compliance Dashboard</Text>
-          <Spacer inline x={1} />
-          <NewBadge>
-            <img src={sparkle} />
-            New
-          </NewBadge>
-        </Container>
-        <Spacer y={1} />
-        <DonutChart data={soc2Data} />
-      </Fieldset>
-
-      <SOC2Checks
-        enableAll={soc2Enabled}
-        soc2Data={soc2Data}
-        setSoc2Data={setSoc2Data}
-        readOnly={isReadOnly}
-      />
-      <Spacer y={1} />
-      <Container row>
-        <Button
-          disabled={isDisabled() ?? isLoading}
-          onClick={applySettings}
-          status={getStatus()}
-        >
-          Save settings
-        </Button>
-        <Spacer inline x={1} />
-        <ToggleRow
-          isToggled={soc2Enabled}
-          onToggle={() => {
-            setSoc2Enabled((prev) => !prev);
-          }}
-          disabled={isReadOnly}
-          disabledTooltip={
-            "Wait for provisioning to complete before editing this field."
-          }
-        >
-          <Container row>
-            <Text>Enable All</Text>
-          </Container>
-        </ToggleRow>
-      </Container>
-    </StyledCompliance>
-  );
-};
-
-export default Compliance;
-
-const StyledCompliance = styled.div``;
-
-const NewBadge = styled.div`
-  font-size: 13px;
-  padding: 5px 10px;
-  background: linear-gradient(110deg, #b6d5f2, #6836e2);
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-
-  > img {
-    height: 14px;
-    margin-right: 5px;
-  }
-`;

+ 0 - 135
dashboard/src/main/home/cluster-dashboard/dashboard/DonutChart.tsx

@@ -1,135 +0,0 @@
-import React, { useEffect, useState } from "react";
-import { ArcElement, CategoryScale, Chart, Legend, Tooltip } from "chart.js";
-import { Doughnut } from "react-chartjs-2";
-
-import Container from "components/porter/Container";
-import Spacer from "components/porter/Spacer";
-import { type Soc2Check } from "shared/types";
-
-Chart.register(ArcElement, Tooltip, Legend, CategoryScale);
-
-type DonutChartProps = {
-    data: Soc2Check;
-};
-
-const DonutChart: React.FC<DonutChartProps> = ({ data }) => {
-    const [chartDataValues, setChartDataValues] = useState([0, 0, 0]);
-
-    useEffect(() => {
-        const counts = { ENABLED: 0, DISABLED: 0, PENDING: 0 };
-
-        Object.values(data.soc2_checks).forEach((check) => {
-            let status = check.status || "DISABLED";
-            if (status.includes("PENDING")) {
-                status = "PENDING";
-            }
-            counts[status.toUpperCase()]++;
-        });
-
-        setChartDataValues([counts.ENABLED, counts.DISABLED, counts.PENDING]);
-    }, [data]); // Dependency array ensures this runs only when `data` changes
-
-    const chartData = {
-        labels: ["Enabled", "Disabled", "Pending"],
-        datasets: [
-            {
-                data: chartDataValues,
-                backgroundColor: ["#5eaa7d", "#e34040", "rgb(255, 205, 86)"],
-                borderColor: "#171b21",
-                borderWidth: 2,
-                hoverBorderColor: "#171b21",
-                hoverBorderWidth: 3,
-                borderJoinStyle: "round",
-                hoverBorderJoinStyle: "bevel",
-            },
-        ],
-    };
-
-    const options = {
-        plugins: {
-            legend: false,
-            tooltip: {},
-        },
-        elements: {
-            arc: {
-                borderWidth: 3,
-                borderColor: "#fff",
-                borderAlign: "inner",
-                hoverOffset: 1,
-            },
-        },
-        responsive: true,
-        maintainAspectRatio: false,
-    };
-
-    const textCenter = {
-        id: "textCenter",
-        afterDatasetsDraw: (chart: unknown) => {
-            const { ctx, data } = chart;
-            ctx.save();
-            ctx.font = "15px sans-serif";
-            ctx.fillStyle = "#fff";
-            ctx.textAlign = "center";
-            ctx.textBaseline = "middle";
-
-            // Calculate the total
-            const total = data.datasets[0].data.reduce((a, b) => a + b, 0);
-
-            // Coordinates for the text
-            const x = chart.getDatasetMeta(0).data[0].x;
-            const y = chart.getDatasetMeta(0).data[0].y;
-
-            // Draw the first line of text
-            ctx.fillText(`${data.datasets[0].data[0]} / ${total}`, x, y - 10); // Adjust Y position as needed
-
-            // Draw the second line of text
-            ctx.fillText(`checks enabled`, x, y + 10); // Adjust Y position as needed
-
-            ctx.restore();
-        },
-    };
-
-    const CustomLegend = (): JSX.Element => (
-        <div
-            style={{
-                display: "flex",
-                flexDirection: "column",
-                justifyContent: "center",
-                alignItems: "start",
-            }}
-        >
-            {chartData.datasets[0].backgroundColor.map((color, index) => (
-                <div
-                    key={index}
-                    style={{ display: "flex", alignItems: "center", marginBottom: "4px" }}
-                >
-                    <span
-                        style={{
-                            backgroundColor: color,
-                            width: "12px",
-                            height: "12px",
-                            display: "inline-block",
-                            marginRight: "8px",
-                        }}
-                    ></span>
-                    {chartData.labels[index]}
-                </div>
-            ))}
-        </div>
-    );
-
-    return (
-        <>
-            <Spacer y={0.5} />
-            <Container row>
-                <div style={{ width: "300px", height: "300px" }}>
-                    <Doughnut data={chartData} options={options} plugins={[textCenter]} />
-                </div>
-                <Spacer inline x={1} />
-                <CustomLegend />
-            </Container>
-        </>
-    );
-};
-
-export default DonutChart;

+ 0 - 246
dashboard/src/main/home/cluster-dashboard/dashboard/PorterAppDashboard.tsx

@@ -1,246 +0,0 @@
-import React, { useState, useContext, useEffect } from "react";
-import styled from "styled-components";
-import { RouteComponentProps, withRouter } from "react-router";
-
-import gradient from "assets/gradient.png";
-
-import { Context } from "shared/Context";
-import { InfraType } from "shared/types";
-import api from "shared/api";
-import { pushFiltered, pushQueryParams } from "shared/routing";
-import { withAuth, WithAuthProps } from "shared/auth/AuthorizationHoc";
-
-import ProvisionerSettings from "../provisioner/ProvisionerSettings";
-import ClusterPlaceholderContainer from "../ClusterPlaceholderContainer";
-import TabRegion from "components/TabRegion";
-import FormDebugger from "components/porter-form/FormDebugger";
-import TitleSection from "components/TitleSection";
-import ClusterSection from "../dashboard/ClusterSection";
-import { StatusPage } from "../onboarding/steps/ProvisionResources/forms/StatusPage";
-import Banner from "components/porter/Banner";
-import Spacer from "components/porter/Spacer";
-
-type Props = RouteComponentProps & WithAuthProps & {
-  projectId: number | null;
-  setRefreshClusters: (x: boolean) => void;
-};
-
-const PorterAppDashboard: React.FC<Props> = ({
-  projectId,
-  setRefreshClusters,
-  ...props
-}) => {
-  const { currentProject, user, capabilities, usage } = useContext(Context);
-  const [infras, setInfras] = useState<InfraType[]>([]);
-  const [pressingCtrl, setPressingCtrl] = useState(false);
-  const [pressingK, setPressingK] = useState(false);
-  const [showFormDebugger, setShowFormDebugger] = useState(false);
-  const [tabOptions, setTabOptions] = useState([{
-    label: "Connected clusters",
-    value: "overview"
-  }]);
-
-  const handleKeyDown = (e: KeyboardEvent): void => {
-    if (e.key === "k") {
-      setPressingK(true);
-    }
-    if (e.key === "Meta" || e.key === "Control") {
-      setPressingCtrl(true);
-    }
-    if (e.key === "z" && pressingK && pressingCtrl) {
-      setPressingK(false);
-      setPressingCtrl(false);
-      setShowFormDebugger(!showFormDebugger);
-    }
-  };
-
-  const handleKeyUp = (e: KeyboardEvent): void => {
-    if (e.key === "Meta" || e.key === "Control" || e.key === "k") {
-      setPressingK(false);
-      setPressingCtrl(false);
-    }
-  };
-
-  useEffect(() => {
-    document.addEventListener("keydown", handleKeyDown);
-    document.addEventListener("keyup", handleKeyUp);
-    return () => {
-      document.removeEventListener("keydown", handleKeyDown);
-      document.removeEventListener("keyup", handleKeyUp);
-    };
-  }, []);
-
-  useEffect(() => {
-    if (currentProject) {
-      if (currentProject.simplified_view_enabled) {
-        pushFiltered(props, "/apps", ["project_id"]);
-      }
-      api
-        .getInfra(
-          "<token>",
-          {},
-          {
-            project_id: currentProject.id,
-          }
-        )
-        .then((res) => setInfras(res.data))
-        .catch(console.log);
-    }
-  }, [currentProject]);
-
-  const currentTab = () => new URLSearchParams(props.location.search).get("tab") || "overview";
-
-  useEffect(() => {
-    if (usage && usage?.current?.clusters < usage?.limit?.clusters) {
-      tabOptions.push({ label: "Create a cluster", value: "create-cluster" });
-    }
-
-    tabOptions.push({ label: "Provisioner status", value: "provisioner" });
-
-    if (!capabilities?.provisioner) {
-      let newTabs = [{ label: "Project overview", value: "overview" }];
-      setTabOptions(newTabs);
-    } else {
-      setTabOptions(tabOptions);
-    }
-  }, [currentProject]);
-
-  const renderTabContents = () => {
-
-    return <ClusterPlaceholderContainer />;
-  };
-
-  return (
-    <>
-      {currentProject && (
-        <DashboardWrapper>
-          {showFormDebugger ? (
-            <FormDebugger
-              goBack={() => setShowFormDebugger(false)}
-            />
-          ) : (
-            <>
-              <TitleSection>
-                {/* <DashboardIcon>
-                  <DashboardImage src={gradient} />
-                  <Overlay>
-                    {currentProject && currentProject.name[0].toUpperCase()}
-                  </Overlay>
-                </DashboardIcon>
-                {currentProject && currentProject.name}
-                {currentProject?.roles?.filter((obj: any) => {
-                  return obj.user_id === user.userId;
-                })[0].kind === "admin" || (
-                    <i
-                      className="material-icons"
-                      onClick={() => {
-                        pushFiltered(props, "/project-settings", ["project_id"]);
-                      }}
-                    >
-                      more_vert
-                    </i>
-                  )} */}
-                Select Cluster
-              </TitleSection>
-              <Spacer height="15px" />
-              {
-                <ClusterPlaceholderContainer />
-                // <TabRegion
-                //   currentTab={currentTab()}
-                //   setCurrentTab={(x: string) => {
-                //     pushQueryParams(props, { tab: x });
-                //   }}
-                //   options={tabOptions}
-                // >
-                //   {renderTabContents()}
-                // </TabRegion>
-
-              }
-            </>
-          )}
-        </DashboardWrapper>
-      )}
-    </>
-  );
-};
-
-export default withRouter(withAuth(PorterAppDashboard));
-
-const Br = styled.div`
-  width: 100%;
-  height: 1px;
-`;
-
-const DashboardWrapper = styled.div`
-  padding-bottom: 100px;
-`;
-
-const TopRow = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Description = styled.div`
-  color: #aaaabb;
-  margin-top: 13px;
-  margin-left: 2px;
-  font-size: 13px;
-`;
-
-const InfoLabel = styled.div`
-  width: 72px;
-  height: 20px;
-  display: flex;
-  align-items: center;
-  color: #aaaabb;
-  font-size: 13px;
-  > i {
-    color: #aaaabb;
-    font-size: 18px;
-    margin-right: 5px;
-  }
-`;
-
-const InfoSection = styled.div`
-  margin-top: 20px;
-  font-family: "Work Sans", sans-serif;
-  margin-left: 0px;
-  margin-bottom: 30px;
-`;
-
-const Overlay = styled.div`
-  height: 100%;
-  width: 100%;
-  position: absolute;
-  top: 0;
-  left: 0;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 21px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  color: white;
-`;
-
-const DashboardImage = styled.img`
-  height: 35px;
-  width: 35px;
-  border-radius: 5px;
-`;
-
-const DashboardIcon = styled.div`
-  position: relative;
-  height: 35px;
-  margin-right: 17px;
-  width: 35px;
-  border-radius: 5px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  > i {
-    font-size: 22px;
-  }
-`;

+ 0 - 104
dashboard/src/main/home/cluster-dashboard/expanded-chart/DeploymentTypeStacks.tsx

@@ -1,104 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-
-import { integrationList } from "shared/common";
-import { ChartType } from "shared/types";
-
-type Props = {
-  appData: any;
-};
-
-const DeploymentTypeStacks: React.FC<Props> = ({ appData }) => {
-  const [showRepoTooltip, setShowRepoTooltip] = useState(false);
-
-  const githubRepository = appData?.app.repo_name;
-  const icon = githubRepository
-    ? integrationList.repo.icon
-    : integrationList.registry.icon;
-
-  const repository =
-    githubRepository ||
-    appData.cluster?.image_repo_uri ||
-    appData.cluster?.config?.image?.repository;
-
-  if (repository?.includes("hello-porter")) {
-    return null;
-  }
-
-  return (
-    <DeploymentImageContainer>
-      <DeploymentTypeIcon src={icon} />
-      <RepositoryName
-        onMouseOver={() => {
-          setShowRepoTooltip(true);
-        }}
-        onMouseOut={() => {
-          setShowRepoTooltip(false);
-        }}
-      >
-        {repository}
-      </RepositoryName>
-      {showRepoTooltip && <Tooltip>{repository}</Tooltip>}
-    </DeploymentImageContainer>
-  );
-};
-
-export default DeploymentTypeStacks;
-
-const DeploymentImageContainer = styled.div`
-  height: 20px;
-  font-size: 13px;
-  position: relative;
-  display: flex;
-  margin-left: 15px;
-  margin-bottom: -3px;
-  align-items: center;
-  font-weight: 400;
-  justify-content: center;
-  color: #ffffff66;
-  padding-left: 5px;
-`;
-
-const Icon = styled.img`
-  width: 100%;
-`;
-
-const DeploymentTypeIcon = styled(Icon)`
-  width: 20px;
-  margin-right: 10px;
-`;
-
-const RepositoryName = styled.div`
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  max-width: 390px;
-  position: relative;
-  margin-right: 3px;
-`;
-
-const Tooltip = styled.div`
-  position: absolute;
-  left: -40px;
-  top: 28px;
-  min-height: 18px;
-  max-width: calc(700px);
-  padding: 5px 7px;
-  background: #272731;
-  z-index: 999;
-  color: white;
-  font-size: 12px;
-  font-family: "Work Sans", sans-serif;
-  outline: 1px solid #ffffff55;
-  opacity: 0;
-  animation: faded-in 0.2s 0.15s;
-  animation-fill-mode: forwards;
-  @keyframes faded-in {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;

+ 0 - 115
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy/DeploySection.tsx

@@ -1,115 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-import Loading from "components/Loading";
-import { Context } from "shared/Context";
-import { ChartType } from "shared/types";
-
-import EventTab from "./EventTab";
-
-type PropsType = {
-  currentChart: ChartType;
-};
-
-type StateType = {
-  events: any[];
-  loading: boolean;
-};
-
-export default class StatusSection extends Component<PropsType, StateType> {
-  state = {
-    events: [] as any[],
-    loading: true,
-  };
-
-  renderTabs = () => {
-    return this.state.events.map((c, i) => {
-      return <EventTab />;
-    });
-  };
-
-  renderStatusSection = () => {
-    if (this.state.loading) {
-      return (
-        <NoEvents>
-          <Loading />
-        </NoEvents>
-      );
-    }
-    if (this.state.events.length > 0) {
-      return <Wrapper>{this.renderTabs()}</Wrapper>;
-    } else {
-      return (
-        <NoEvents>
-          <i className="material-icons">category</i>
-          No events to display. This might happen while your app is still
-          deploying.
-        </NoEvents>
-      );
-    }
-  };
-
-  componentDidMount() {
-    const { currentChart } = this.props;
-    let { currentCluster, currentProject, setCurrentError } = this.context;
-
-    // api.getChartEvents('<token>', {
-    //   namespace: currentChart.namespace,
-    //   cluster_id: currentCluster.id,
-    //   storage: StorageType.Secret
-    // }, {
-    //   id: currentProject.id,
-    //   name: currentChart.name,
-    //   revision: currentChart.version
-    // }, (err: any, res: any) => {
-    //   if (err) {
-    //     setCurrentError(JSON.stringify(err));
-    //     return
-    //   }
-    //   this.setState({ controllers: res.data, loading: false })
-    // });
-    this.setState({ events: [1, 2, 3], loading: false });
-  }
-
-  render() {
-    return (
-      <StyledDeploySection>{this.renderStatusSection()}</StyledDeploySection>
-    );
-  }
-}
-
-StatusSection.contextType = Context;
-
-const StyledDeploySection = styled.div`
-  width: 100%;
-  height: 100%;
-  position: relative;
-  font-size: 13px;
-  padding: 0px;
-  user-select: text;
-  border-radius: 5px;
-  overflow: hidden;
-`;
-
-const Wrapper = styled.div`
-  width: 100%;
-  height: 100%;
-  overflow-y: auto;
-  min-width: 250px;
-`;
-
-const NoEvents = styled.div`
-  padding-top: 20%;
-  position: relative;
-  width: 100%;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  color: #ffffff44;
-  font-size: 14px;
-
-  > i {
-    font-size: 18px;
-    margin-right: 12px;
-  }
-`;

+ 0 - 42
dashboard/src/main/home/cluster-dashboard/expanded-chart/deploy/EventTab.tsx

@@ -1,42 +0,0 @@
-import React, { Component } from "react";
-import styled from "styled-components";
-
-type PropsType = {};
-
-type StateType = {};
-
-export default class EventTab extends Component<PropsType, StateType> {
-  state = {};
-
-  render() {
-    return (
-      <StyledEventTab isLast={false}>
-        <EventHeader>
-          <i className="material-icons">cloud_upload</i>
-          Deploy successful!
-          <div>Dec 12 at 11:55AM</div>
-        </EventHeader>
-      </StyledEventTab>
-    );
-  }
-}
-
-const StyledEventTab = styled.div`
-  width: 100%;
-  margin-bottom: 2px;
-  background: #ffffff11;
-  border-bottom-left-radius: ${(props: { isLast: boolean }) =>
-    props.isLast ? "5px" : ""};
-`;
-
-const EventHeader = styled.div`
-  width: 100%;
-  height: 50px;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  color: #ffffff66;
-  user-select: none;
-  padding: 8px 18px;
-  padding-left: 22px;
-`;

+ 0 - 135
dashboard/src/main/home/cluster-dashboard/expanded-chart/jobs/TempJobList.tsx

@@ -1,135 +0,0 @@
-import React, { useContext, useState } from "react";
-import styled from "styled-components";
-
-import { PorterFormContext } from "components/porter-form/PorterFormContextProvider";
-import JobList from "./JobList";
-import SaveButton from "components/SaveButton";
-import CommandLineIcon from "assets/command-line-icon";
-import ConnectToJobInstructionsModal from "./ConnectToJobInstructionsModal";
-import Loading from "components/Loading";
-
-interface Props {
-  isAuthorized: any;
-  saveValuesStatus: string;
-  setJobs: any;
-  jobs: any;
-  handleSaveValues: any;
-  expandJob: any;
-  currentChartVersion: number;
-  latestChartVersion: number;
-  isDeployedFromGithub: boolean;
-  repositoryUrl?: string;
-  chartName: string;
-  isLoading: boolean;
-}
-
-/**
- * Temporary functional component for allowing job rerun button to consume
- * form context (until ExpandedJobChart is migrated to FC)
- */
-const TempJobList: React.FC<Props> = (props) => {
-  const { getSubmitValues } = useContext(PorterFormContext);
-  const [showConnectionModal, setShowConnectionModal] = useState(false);
-  const [searchInput, setSearchInput] = useState("");
-
-  let saveButton = (
-    <ButtonWrapper>
-      <SaveButton
-        onClick={() => {
-          props.handleSaveValues(getSubmitValues(), true);
-        }}
-        status={props.saveValuesStatus}
-        makeFlush={true}
-        clearPosition={true}
-        rounded={true}
-        statusPosition="right"
-      >
-        <i className="material-icons">play_arrow</i> Run Job
-      </SaveButton>
-      <CLIModalIconWrapper
-        onClick={(e) => {
-          e.preventDefault();
-          setShowConnectionModal(true);
-        }}
-      >
-        <CLIModalIcon />
-        Shell Access
-      </CLIModalIconWrapper>
-    </ButtonWrapper>
-  );
-
-  if (!props.isAuthorized("job", "", ["get", "update", "create"])) {
-    saveButton = null;
-  }
-
-  if (props.isLoading) {
-    return <Loading height="500px"></Loading>;
-  }
-
-  return (
-    <>
-      {saveButton}
-      <JobList
-        jobs={props.jobs}
-        setJobs={props.setJobs}
-        expandJob={props.expandJob}
-        isDeployedFromGithub={props.isDeployedFromGithub}
-        repositoryUrl={props.repositoryUrl}
-        currentChartVersion={props.currentChartVersion}
-        latestChartVersion={props.latestChartVersion}
-      />
-      <ConnectToJobInstructionsModal
-        show={showConnectionModal}
-        onClose={() => setShowConnectionModal(false)}
-        chartName={props.chartName}
-      />
-    </>
-  );
-};
-
-export default TempJobList;
-
-const ButtonWrapper = styled.div`
-  display: flex;
-  margin: 5px 0 35px;
-  justify-content: space-between;
-`;
-
-const CLIModalIconWrapper = styled.div`
-  height: 35px;
-  font-size: 13px;
-  font-weight: 500;
-  font-family: "Work Sans", sans-serif;
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 6px 20px 6px 10px;
-  text-align: left;
-  border: 1px solid #ffffff55;
-  border-radius: 8px;
-  background: #ffffff11;
-  color: #ffffffdd;
-  cursor: pointer;
-
-  :hover {
-    cursor: pointer;
-    background: #ffffff22;
-    > path {
-      fill: #ffffff77;
-    }
-  }
-
-  > path {
-    fill: #ffffff99;
-  }
-`;
-
-const CLIModalIcon = styled(CommandLineIcon)`
-  width: 32px;
-  height: 32px;
-  padding: 8px;
-
-  > path {
-    fill: #ffffff99;
-  }
-`;

+ 0 - 52
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ConnectToLogsInstructionModal.tsx

@@ -1,52 +0,0 @@
-import Modal from "main/home/modals/Modal";
-import React from "react";
-import styled from "styled-components";
-
-const ConnectToLogsInstructionModal: React.FC<{
-  show: boolean;
-  onClose: () => void;
-  chartName: string;
-  namespace: string;
-}> = ({ show, chartName, namespace, onClose }) => {
-  if (!show) {
-    return null;
-  }
-
-  return (
-    <Modal
-      onRequestClose={() => onClose()}
-      width="700px"
-      height="300px"
-      title="Shell Access Instructions"
-    >
-      To get shell live logs for this pod, make sure you have the Porter CLI
-      installed (installation instructions&nbsp;
-      <a href={"https://docs.porter.run/cli/installation"} target="_blank">
-        here
-      </a>
-      ).
-      <br />
-      <br />
-      Run the following line of code:
-      <Code>
-        porter logs {chartName || "[APP-NAME]"} --follow --namespace{" "}
-        {namespace || "[NAMESPACE]"}
-      </Code>
-    </Modal>
-  );
-};
-
-export default ConnectToLogsInstructionModal;
-
-const Code = styled.div`
-  background: #181b21;
-  padding: 10px 15px;
-  border: 1px solid #ffffff44;
-  border-radius: 5px;
-  margin: 10px 0px 15px;
-  color: #ffffff;
-  font-size: 13px;
-  user-select: text;
-  line-height: 1em;
-  font-family: monospace;
-`;

+ 0 - 123
dashboard/src/main/home/cluster-dashboard/preview-environments/components/RecreateWorkflowFilesModal.tsx

@@ -1,123 +0,0 @@
-import Modal from "main/home/modals/Modal";
-import React from "react";
-import styled from "styled-components";
-
-type Props = {
-  hide: boolean;
-  isReEnable: boolean;
-  onClose: () => void;
-};
-
-const RecreateWorkflowFilesModal = (props: Props) => {
-  const createNewWorkflows = () => {};
-
-  if (props.hide) {
-    return null;
-  }
-
-  return (
-    <Modal title="Workflow files not found">
-      <div>
-        <div>
-          We couldn't find any workflow files to process the{" "}
-          {props.isReEnable
-            ? "re enabling of this preview environment"
-            : "creation of this preview environment"}
-          .
-          <HighlightText>
-            Do you want to create the workflow files? Or Remove the repository?
-          </HighlightText>
-          <Warning highlight>
-            ⚠️ If the workflow files don't exist, Porter will not be able to
-            create any preview environment for this repository.
-          </Warning>
-        </div>
-
-        <ActionWrapper>
-          <DeleteButton onClick={() => props.onClose()}>Close</DeleteButton>
-          <CancelButton onClick={() => createNewWorkflows()}>
-            Create new workflows
-          </CancelButton>
-        </ActionWrapper>
-      </div>
-    </Modal>
-  );
-};
-
-export default RecreateWorkflowFilesModal;
-
-const Button = styled.button`
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  font-size: 13px;
-  cursor: pointer;
-  font-family: "Work Sans", sans-serif;
-  border-radius: 10px;
-  color: white;
-  height: 35px;
-  padding: 10px 16px;
-  font-weight: 500;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-  cursor: pointer;
-  border: none;
-  :not(:last-child) {
-    margin-right: 10px;
-  }
-`;
-
-const DeleteButton = styled(Button)`
-  ${({ disabled }: { disabled?: boolean }) => {
-    if (disabled) {
-      return `
-      background: #aaaabbee;
-      :hover {
-        background: #aaaabbee;
-      }    
-      `;
-    }
-
-    return `
-      background: #dd4b4b;
-      :hover {
-        background: #b13d3d;
-      }`;
-  }}
-`;
-
-const CancelButton = styled(Button)`
-  background: #616feecc;
-  :hover {
-    background: #505edddd;
-  }
-`;
-
-const ActionWrapper = styled.div`
-  display: flex;
-  align-items: center;
-`;
-
-const Warning = styled.div`
-  font-size: 13px;
-  display: flex;
-  width: 100%;
-  margin-top: 10px;
-  line-height: 1.4em;
-  align-items: center;
-  color: white;
-  > i {
-    margin-right: 10px;
-    font-size: 18px;
-  }
-  color: ${(props: { highlight: boolean }) =>
-    props.highlight ? "#f5cb42" : ""};
-`;
-
-const HighlightText = styled.div`
-  font-size: 16px;
-  font-weight: bold;
-  color: #ffffff;
-`;

+ 34 - 35
dashboard/src/main/home/cluster-dashboard/preview-environments/deployments/DeploymentList.tsx

@@ -1,27 +1,29 @@
 import React, { useContext, useEffect, useMemo, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
-import styled from "styled-components";
-import Loading from "components/Loading";
 import _ from "lodash";
-import DeploymentCard from "./DeploymentCard";
-import { Environment, PRDeployment, PullRequest } from "../types";
-import { useRouting } from "shared/routing";
 import { useHistory, useLocation, useParams } from "react-router";
-import { deployments, pull_requests } from "../mocks";
+import styled from "styled-components";
+
 import DynamicLink from "components/DynamicLink";
-import DashboardHeader from "../../DashboardHeader";
-import RadioFilter from "components/RadioFilter";
+import Loading from "components/Loading";
 import Placeholder from "components/Placeholder";
 import Banner from "components/porter/Banner";
+import RadioFilter from "components/RadioFilter";
 
-import pullRequestIcon from "assets/pull_request_icon.svg";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import { search } from "shared/search";
 import filterOutline from "assets/filter-outline.svg";
+import pullRequestIcon from "assets/pull_request_icon.svg";
 import sort from "assets/sort.svg";
-import { search } from "shared/search";
-import { getPRDeploymentList, validatePorterYAML } from "../utils";
-import { PorterYAMLErrors } from "../errors";
+
+import DashboardHeader from "../../DashboardHeader";
 import PorterYAMLErrorsModal from "../components/PorterYAMLErrorsModal";
+import { PorterYAMLErrors } from "../errors";
+import { deployments, pull_requests } from "../mocks";
+import { Environment, type PRDeployment, type PullRequest } from "../types";
+import { getPRDeploymentList, validatePorterYAML } from "../utils";
+import DeploymentCard from "./DeploymentCard";
 
 const AvailableStatusFilters = [
   "all",
@@ -32,7 +34,7 @@ const AvailableStatusFilters = [
   "updating",
 ];
 
-type AvailableStatusFiltersType = typeof AvailableStatusFilters[number];
+type AvailableStatusFiltersType = (typeof AvailableStatusFilters)[number];
 
 const DeploymentList = () => {
   const [sortOrder, setSortOrder] = useState("Newest");
@@ -47,14 +49,11 @@ const DeploymentList = () => {
     string[]
   >([]);
 
-  const [
-    statusSelectorVal,
-    setStatusSelectorVal,
-  ] = useState<AvailableStatusFiltersType>("all");
+  const [statusSelectorVal, setStatusSelectorVal] =
+    useState<AvailableStatusFiltersType>("all");
 
-  const { currentProject, currentCluster, setCurrentError } = useContext(
-    Context
-  );
+  const { currentProject, currentCluster, setCurrentError } =
+    useContext(Context);
   const { getQueryParam, pushQueryParams } = useRouting();
   const location = useLocation();
   const history = useHistory();
@@ -66,8 +65,8 @@ const DeploymentList = () => {
 
   const selectedRepo = `${repo_owner}/${repo_name}`;
 
-  const getEnvironment = () => {
-    return api.getEnvironment(
+  const getEnvironment = async () => {
+    return await api.getEnvironment(
       "<token>",
       {},
       {
@@ -241,7 +240,7 @@ const DeploymentList = () => {
       return (
         <Placeholder height="calc(100vh - 400px)">
           No preview developments have been found. Open a PR to create a new
-          preview app.
+          preview environment.
         </Placeholder>
       );
     }
@@ -279,7 +278,9 @@ const DeploymentList = () => {
     <>
       <PorterYAMLErrorsModal
         errors={expandedPorterYAMLErrors}
-        onClose={() => setExpandedPorterYAMLErrors([])}
+        onClose={() => {
+          setExpandedPorterYAMLErrors([]);
+        }}
         repo={selectedRepo}
       />
 
@@ -380,15 +381,13 @@ const DeploymentList = () => {
 
 export default DeploymentList;
 
-const mockRequest = () =>
-  new Promise((res) => {
-    setTimeout(
-      () =>
-        res({
-          data: { deployments: deployments, pull_requests: pull_requests },
-        }),
-      1000
-    );
+const mockRequest = async () =>
+  await new Promise((res) => {
+    setTimeout(() => {
+      res({
+        data: { deployments, pull_requests },
+      });
+    }, 1000);
   });
 
 const LoadingWrapper = styled.div`

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels