Forráskód Böngészése

new datastore create form (#4511)

Feroze Mohideen 2 éve
szülő
commit
9849c97c5a
27 módosított fájl, 1184 hozzáadás és 1050 törlés
  1. 2 2
      api/server/handlers/datastore/get.go
  2. 48 17
      api/server/handlers/datastore/update.go
  3. 142 0
      dashboard/src/components/porter/BlockSelect.tsx
  4. 14 7
      dashboard/src/components/porter/Tooltip.tsx
  5. 80 24
      dashboard/src/lib/databases/types.ts
  6. 56 4
      dashboard/src/lib/hooks/useDatastore.ts
  7. 4 13
      dashboard/src/lib/porter-apps/index.ts
  8. 2 11
      dashboard/src/main/home/Home.tsx
  9. 2 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts
  10. 0 185
      dashboard/src/main/home/database-dashboard/CreateDatabase.tsx
  11. 131 0
      dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx
  12. 205 107
      dashboard/src/main/home/database-dashboard/constants.ts
  13. 14 0
      dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx
  14. 0 236
      dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx
  15. 0 136
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx
  16. 0 135
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx
  17. 0 154
      dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx
  18. 425 0
      dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx
  19. 6 3
      dashboard/src/main/home/database-dashboard/icons.tsx
  20. 2 2
      dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx
  21. 29 6
      dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx
  22. 9 0
      dashboard/src/main/home/database-dashboard/shared/Resources.tsx
  23. 1 1
      dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx
  24. 2 2
      dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx
  25. 2 2
      dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx
  26. 2 1
      dashboard/src/shared/api.tsx
  27. 6 1
      internal/models/datastore.go

+ 2 - 2
api/server/handlers/datastore/get.go

@@ -80,7 +80,7 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	}
 
 	// TODO: delete this branch once all datastores are on the management cluster
-	if !datastoreRecord.OnManagementCluster {
+	if datastoreRecord.IsLegacy() {
 		awsArn, err := arn.Parse(datastoreRecord.CloudProviderCredentialIdentifier)
 		if err != nil {
 			err = telemetry.Error(ctx, span, err, "error parsing aws account id")
@@ -107,7 +107,7 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Status:                            string(datastoreRecord.Status),
 		CloudProvider:                     SupportedDatastoreCloudProvider_AWS,
 		CloudProviderCredentialIdentifier: datastoreRecord.CloudProviderCredentialIdentifier,
-		OnManagementCluster:               true,
+		OnManagementCluster:               datastoreRecord.OnManagementCluster,
 	}
 
 	// this is done for backwards compatibility; eventually we will just return proto

+ 48 - 17
api/server/handlers/datastore/update.go

@@ -73,18 +73,8 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 		telemetry.AttributeKV{Key: "engine", Value: request.Engine},
 	)
 
-	region, err := h.getClusterRegion(ctx, project.ID, cluster.ID)
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error getting cluster region")
-		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-
 	// assume we are creating for now; will add update support later
 	datastoreProto := &porterv1.ManagedDatastore{
-		CloudProvider:                     porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS,
-		CloudProviderCredentialIdentifier: cluster.CloudProviderCredentialIdentifier,
-		Region:                            region,
 		ConnectedClusters: &porterv1.ConnectedClusters{
 			ConnectedClusterIds: []int64{int64(cluster.ID)},
 		},
@@ -98,13 +88,15 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 
 	var datastoreValues struct {
 		Config struct {
-			Name               string `json:"name"`
-			DatabaseName       string `json:"databaseName"`
-			MasterUsername     string `json:"masterUsername"`
-			MasterUserPassword string `json:"masterUserPassword"`
-			AllocatedStorage   int64  `json:"allocatedStorage"`
-			InstanceClass      string `json:"instanceClass"`
-			EngineVersion      string `json:"engineVersion"`
+			Name               string  `json:"name"`
+			DatabaseName       string  `json:"databaseName"`
+			MasterUsername     string  `json:"masterUsername"`
+			MasterUserPassword string  `json:"masterUserPassword"`
+			AllocatedStorage   int64   `json:"allocatedStorage"`
+			InstanceClass      string  `json:"instanceClass"`
+			EngineVersion      string  `json:"engineVersion"`
+			CpuCores           float32 `json:"cpuCores"`
+			RamMegabytes       int     `json:"ramMegabytes"`
 		} `json:"config"`
 	}
 	err = json.Unmarshal(marshaledValues, &datastoreValues)
@@ -134,6 +126,15 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 			return
 		}
+		region, err := h.getClusterRegion(ctx, project.ID, cluster.ID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error getting cluster region")
+			h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		datastoreProto.Region = region
+		datastoreProto.CloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+		datastoreProto.CloudProviderCredentialIdentifier = cluster.CloudProviderCredentialIdentifier
 		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_AWS_RDS
 		datastoreProto.KindValues = &porterv1.ManagedDatastore_AwsRdsKind{
 			AwsRdsKind: &porterv1.AwsRds{
@@ -147,6 +148,15 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 			},
 		}
 	case "ELASTICACHE":
+		region, err := h.getClusterRegion(ctx, project.ID, cluster.ID)
+		if err != nil {
+			err = telemetry.Error(ctx, span, err, "error getting cluster region")
+			h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+			return
+		}
+		datastoreProto.Region = region
+		datastoreProto.CloudProvider = porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_AWS
+		datastoreProto.CloudProviderCredentialIdentifier = cluster.CloudProviderCredentialIdentifier
 		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_AWS_ELASTICACHE
 		datastoreProto.KindValues = &porterv1.ManagedDatastore_AwsElasticacheKind{
 			AwsElasticacheKind: &porterv1.AwsElasticache{
@@ -156,6 +166,27 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 				EngineVersion:             pointer.String(datastoreValues.Config.EngineVersion),
 			},
 		}
+	case "MANAGED-POSTGRES":
+		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_MANAGED_POSTGRES
+		datastoreProto.KindValues = &porterv1.ManagedDatastore_ManagedPostgresKind{
+			ManagedPostgresKind: &porterv1.Postgres{
+				CpuCores:                  datastoreValues.Config.CpuCores,
+				RamMegabytes:              int32(datastoreValues.Config.RamMegabytes),
+				StorageGigabytes:          int32(datastoreValues.Config.AllocatedStorage),
+				MasterUsername:            pointer.String(datastoreValues.Config.MasterUsername),
+				MasterUserPasswordLiteral: pointer.String(datastoreValues.Config.MasterUserPassword),
+			},
+		}
+	case "MANAGED-REDIS":
+		datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_MANAGED_REDIS
+		datastoreProto.KindValues = &porterv1.ManagedDatastore_ManagedRedisKind{
+			ManagedRedisKind: &porterv1.Redis{
+				CpuCores:                  datastoreValues.Config.CpuCores,
+				RamMegabytes:              int32(datastoreValues.Config.RamMegabytes),
+				StorageGigabytes:          int32(datastoreValues.Config.AllocatedStorage),
+				MasterUserPasswordLiteral: pointer.String(datastoreValues.Config.MasterUserPassword),
+			},
+		}
 	default:
 		err = telemetry.Error(ctx, span, nil, "invalid datastore type")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))

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

@@ -0,0 +1,142 @@
+import React from "react";
+import styled from "styled-components";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import Tooltip from "components/porter/Tooltip";
+
+export type BlockSelectOption = {
+  name: string;
+  displayName: string;
+  icon: string;
+  description?: string;
+  descriptionColor?: "warner";
+  disabledOpts?: {
+    tooltipText: string;
+  };
+};
+type Props = {
+  options: BlockSelectOption[];
+  selectedOption?: BlockSelectOption;
+  setOption: (option: BlockSelectOption) => void;
+};
+
+const BlockSelect: React.FC<Props> = ({
+  options,
+  selectedOption,
+  setOption,
+}) => {
+  return (
+    <BlockList>
+      {options.map((option) => {
+        return option.disabledOpts ? (
+          <Tooltip content={option.disabledOpts.tooltipText}>
+            <Block
+              key={option.name}
+              selected={selectedOption?.name === option.name}
+              onClick={() => {}}
+              disabled
+            >
+              <Container row>
+                <Icon src={option.icon} />
+                <Spacer inline x={0.5} />
+                <Text>{option.displayName}</Text>
+              </Container>
+              {option.description && (
+                <>
+                  <Spacer y={0.5} />
+                  <Text
+                    size={12}
+                    color={
+                      option.descriptionColor
+                        ? option.descriptionColor
+                        : "helper"
+                    }
+                  >
+                    {option.description}
+                  </Text>
+                </>
+              )}
+            </Block>
+          </Tooltip>
+        ) : (
+          <Block
+            key={option.name}
+            selected={selectedOption?.name === option.name}
+            onClick={() => {
+              setOption(option);
+            }}
+          >
+            <Container row>
+              <Icon src={option.icon} />
+              <Spacer inline x={0.5} />
+              <Text>{option.displayName}</Text>
+            </Container>
+            {option.description && (
+              <>
+                <Spacer y={0.5} />
+                <Text
+                  size={12}
+                  color={
+                    option.descriptionColor ? option.descriptionColor : "helper"
+                  }
+                >
+                  {option.description}
+                </Text>
+              </>
+            )}
+          </Block>
+        );
+      })}
+    </BlockList>
+  );
+};
+
+export default BlockSelect;
+
+const Block = styled.div<{ selected?: boolean; disabled?: boolean }>`
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  align-items: left;
+  user-select: none;
+  font-size: 13px;
+  overflow: hidden;
+  font-weight: 500;
+  padding: 10px 10px;
+  align-item: center;
+  color: #ffffff;
+  position: relative;
+  transition: all 0.2s;
+  border-radius: 5px;
+  filter: ${({ disabled }) => (disabled ? "brightness(0.8) grayscale(1)" : "")};
+  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
+  background: ${(props) => props.theme.clickable.bg};
+  border: ${(props) =>
+    props.selected ? "2px solid #8590ff" : "1px solid #494b4f"};
+  :hover {
+    border: ${({ selected, disabled }) =>
+      !selected && !disabled && "1px solid #7a7b80"};
+  }
+
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const BlockList = styled.div`
+  overflow: visible;
+  margin-top: 6px;
+  display: grid;
+  grid-column-gap: 25px;
+  grid-row-gap: 25px;
+  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+`;

+ 14 - 7
dashboard/src/components/porter/Tooltip.tsx

@@ -2,13 +2,13 @@
 import React, { useState } from "react";
 import styled from "styled-components";
 
-interface TooltipProps {
+type TooltipProps = {
   children: React.ReactNode;
   content: React.ReactNode;
   position?: "top" | "right" | "bottom" | "left";
   hidden?: boolean;
   width?: string;
-}
+};
 
 const Tooltip: React.FC<TooltipProps> = ({
   children,
@@ -19,8 +19,12 @@ const Tooltip: React.FC<TooltipProps> = ({
 }) => {
   const [isVisible, setIsVisible] = useState(false);
 
-  const showTooltip = () => setIsVisible(true);
-  const hideTooltip = () => setIsVisible(false);
+  const showTooltip = () => {
+    setIsVisible(true);
+  };
+  const hideTooltip = () => {
+    setIsVisible(false);
+  };
 
   if (hidden) {
     return <>{children}</>;
@@ -29,7 +33,9 @@ const Tooltip: React.FC<TooltipProps> = ({
   return (
     <TooltipContainer onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
       {isVisible && (
-        <TooltipContent position={position} width={width}>{content}</TooltipContent>
+        <TooltipContent position={position} width={width}>
+          {content}
+        </TooltipContent>
       )}
       {children}
     </TooltipContainer>
@@ -40,10 +46,11 @@ export default Tooltip;
 
 const TooltipContainer = styled.div`
   position: relative;
-  display: inline-flex;
+  width: 100%;
+  height: 100%;
 `;
 
-const TooltipContent = styled.div<{ position: string, width?: string }>`
+const TooltipContent = styled.div<{ position: string; width?: string }>`
   color: #fff;
   padding: 10px;
   border-radius: 5px;

+ 80 - 24
dashboard/src/lib/databases/types.ts

@@ -30,10 +30,24 @@ export type DatastoreConnectionInfo = z.infer<
   typeof datastoreCredentialValidator
 >;
 
+const datastoreTypeValidator = z.enum([
+  "UNKNOWN",
+  "RDS",
+  "ELASTICACHE",
+  "MANAGED_REDIS",
+  "MANAGED_POSTGRES",
+]);
+const datastoreEngineValidator = z.enum([
+  "UNKNOWN",
+  "POSTGRES",
+  "AURORA-POSTGRES",
+  "REDIS",
+  "MEMCACHED",
+]);
 export const datastoreValidator = z.object({
   name: z.string(),
-  type: z.enum(["RDS", "ELASTICACHE"]),
-  engine: z.enum(["POSTGRES", "AURORA-POSTGRES", "REDIS", "MEMCACHED"]),
+  type: z.string().pipe(datastoreTypeValidator.catch("UNKNOWN")),
+  engine: z.string().pipe(datastoreEngineValidator.catch("UNKNOWN")),
   created_at: z.string().default(""),
   metadata: datastoreMetadataValidator.array().default([]),
   env: datastoreEnvValidator.optional(),
@@ -72,22 +86,7 @@ export const datastoreListResponseValidator = z.object({
 export type DatastoreEngine = {
   name: z.infer<typeof datastoreValidator>["engine"];
   displayName: string;
-};
-export const DATASTORE_ENGINE_POSTGRES = {
-  name: "POSTGRES" as const,
-  displayName: "PostgreSQL",
-};
-export const DATASTORE_ENGINE_AURORA_POSTGRES = {
-  name: "AURORA-POSTGRES" as const,
-  displayName: "Aurora PostgreSQL",
-};
-export const DATASTORE_ENGINE_REDIS = {
-  name: "REDIS" as const,
-  displayName: "Redis",
-};
-export const DATASTORE_ENGINE_MEMCACHED = {
-  name: "MEMCACHED" as const,
-  displayName: "Memcached",
+  icon: string;
 };
 
 export type DatastoreType = {
@@ -102,6 +101,14 @@ export const DATASTORE_TYPE_ELASTICACHE: DatastoreType = {
   name: "ELASTICACHE" as const,
   displayName: "ElastiCache",
 };
+export const DATASTORE_TYPE_MANAGED_POSTGRES: DatastoreType = {
+  name: "MANAGED_POSTGRES" as const,
+  displayName: "Managed Postgres",
+};
+export const DATASTORE_TYPE_MANAGED_REDIS: DatastoreType = {
+  name: "MANAGED_REDIS" as const,
+  displayName: "Managed Redis",
+};
 
 export type DatastoreState = {
   state: z.infer<typeof datastoreValidator>["status"];
@@ -153,10 +160,12 @@ export const DATASTORE_STATE_DELETED: DatastoreState = {
 };
 
 export type DatastoreTemplate = {
+  highLevelType: DatastoreEngine; // this was created so that rds aurora postgres and rds postgres can be grouped together
   type: DatastoreType;
   engine: DatastoreEngine;
   icon: string;
   name: string;
+  displayName: string;
   description: string;
   disabled: boolean;
   instanceTiers: ResourceOption[];
@@ -214,6 +223,33 @@ const rdsPostgresConfigValidator = z.object({
     .default(""),
 });
 
+const managedPostgresConfigValidator = z.object({
+  type: z.literal("managed-postgres"),
+  instanceClass: instanceTierValidator
+    .default("unspecified")
+    .refine((val) => val !== "unspecified", {
+      message: "Instance tier is required",
+    }),
+  allocatedStorageGigabytes: z
+    .number()
+    .int()
+    .positive("Allocated storage must be a positive integer")
+    .default(1),
+  // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
+  databaseName: z
+    .string()
+    .nonempty("Database name is required")
+    .default("postgres"),
+  masterUsername: z
+    .string()
+    .nonempty("Master username is required")
+    .default("postgres"),
+  masterUserPassword: z
+    .string()
+    .nonempty("Master password is required")
+    .default(""),
+});
+
 const auroraPostgresConfigValidator = z.object({
   type: z.literal("rds-postgresql-aurora"),
   instanceClass: instanceTierValidator
@@ -227,11 +263,14 @@ const auroraPostgresConfigValidator = z.object({
     .positive("Allocated storage must be a positive integer")
     .default(30),
   // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
-  databaseName: z.string().nonempty("Database name is required").default(""),
+  databaseName: z
+    .string()
+    .nonempty("Database name is required")
+    .default("postgres"),
   masterUsername: z
     .string()
     .nonempty("Master username is required")
-    .default(""),
+    .default("postgres"),
   masterUserPassword: z
     .string()
     .nonempty("Master password is required")
@@ -245,12 +284,20 @@ const elasticacheRedisConfigValidator = z.object({
     .refine((val) => val !== "unspecified", {
       message: "Instance tier is required",
     }),
-  // the following three are not yet specified by the user during creation - only parsed from the backend after the form is submitted
-  databaseName: z.string().nonempty("Database name is required").default(""),
-  masterUsername: z
+  masterUserPassword: z
     .string()
-    .nonempty("Master username is required")
+    .nonempty("Master password is required")
     .default(""),
+  engineVersion: z.string().default("7.1"),
+});
+
+const managedRedisConfigValidator = z.object({
+  type: z.literal("managed-redis"),
+  instanceClass: instanceTierValidator
+    .default("unspecified")
+    .refine((val) => val !== "unspecified", {
+      message: "Instance tier is required",
+    }),
   masterUserPassword: z
     .string()
     .nonempty("Master password is required")
@@ -265,10 +312,19 @@ export const dbFormValidator = z.object({
     .regex(/^[a-z0-9-]+$/, {
       message: "Lowercase letters, numbers, and “-” only.",
     }),
+  workloadType: z
+    .enum(["Production", "Test", "unspecified"])
+    .default("unspecified"),
+  engine: z
+    .string()
+    .pipe(datastoreEngineValidator.catch("UNKNOWN"))
+    .default("UNKNOWN"),
   config: z.discriminatedUnion("type", [
     rdsPostgresConfigValidator,
     auroraPostgresConfigValidator,
     elasticacheRedisConfigValidator,
+    managedRedisConfigValidator,
+    managedPostgresConfigValidator,
   ]),
   clusterId: z.number(),
 });

+ 56 - 4
dashboard/src/lib/hooks/useDatabaseMethods.ts → dashboard/src/lib/hooks/useDatastore.ts

@@ -2,6 +2,7 @@ import { useCallback, useContext } from "react";
 import { useQueryClient } from "@tanstack/react-query";
 import { match } from "ts-pattern";
 
+import { DATASTORE_TEMPLATE_MANAGED_POSTGRES } from "main/home/database-dashboard/constants";
 import { type DbFormData } from "lib/databases/types";
 
 import api from "shared/api";
@@ -20,7 +21,7 @@ type DatastoreHook = {
 };
 type CreateDatastoreInput = {
   name: string;
-  type: "RDS" | "ELASTICACHE";
+  type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS";
   engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
   values: object;
 };
@@ -63,6 +64,33 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
         engine: "AURORA-POSTGRES",
       })
     )
+    .with(
+      { config: { type: "managed-postgres" } },
+      (values): CreateDatastoreInput => {
+        const instanceTypeMatch =
+          DATASTORE_TEMPLATE_MANAGED_POSTGRES.instanceTiers.find(
+            (t) => t.tier === values.config.instanceClass
+          );
+        return {
+          name: values.name,
+          values: {
+            config: {
+              name: values.name,
+              databaseName: values.config.databaseName,
+              masterUsername: values.config.masterUsername,
+              masterUserPassword: values.config.masterUserPassword,
+              allocatedStorage: values.config.allocatedStorageGigabytes,
+              cpuCores: instanceTypeMatch?.cpuCores ?? 1,
+              ramMegabytes: instanceTypeMatch?.ramGigabytes
+                ? instanceTypeMatch.ramGigabytes * 1024
+                : 1024,
+            },
+          },
+          type: "MANAGED-POSTGRES",
+          engine: "POSTGRES",
+        };
+      }
+    )
     .with(
       { config: { type: "elasticache-redis" } },
       (values): CreateDatastoreInput => ({
@@ -70,8 +98,6 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
         values: {
           config: {
             name: values.name,
-            databaseName: values.config.databaseName,
-            masterUsername: values.config.masterUsername,
             masterUserPassword: values.config.masterUserPassword,
             instanceClass: values.config.instanceClass,
             engineVersion: values.config.engineVersion,
@@ -81,10 +107,36 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => {
         engine: "REDIS",
       })
     )
+    .with(
+      { config: { type: "managed-redis" } },
+      (values): CreateDatastoreInput => {
+        const instanceTypeMatch =
+          DATASTORE_TEMPLATE_MANAGED_POSTGRES.instanceTiers.find(
+            (t) => t.tier === values.config.instanceClass
+          );
+
+        return {
+          name: values.name,
+          values: {
+            config: {
+              name: values.name,
+              masterUserPassword: values.config.masterUserPassword,
+              engineVersion: values.config.engineVersion,
+              cpuCores: instanceTypeMatch?.cpuCores ?? 1,
+              ramMegabytes: instanceTypeMatch?.ramGigabytes
+                ? instanceTypeMatch.ramGigabytes * 1024
+                : 1024,
+            },
+          },
+          type: "MANAGED-REDIS",
+          engine: "REDIS",
+        };
+      }
+    )
     .exhaustive();
 };
 
-export const useDatastoreMethods = (): DatastoreHook => {
+export const useDatastore = (): DatastoreHook => {
   const { currentProject, currentCluster } = useContext(Context);
 
   const queryClient = useQueryClient();

+ 4 - 13
dashboard/src/lib/porter-apps/index.ts

@@ -137,18 +137,6 @@ export const basePorterAppFormValidator = z.object({
 
 // porterAppFormValidator is used to validate inputs when creating + updating an app
 export const porterAppFormValidator = basePorterAppFormValidator
-  .refine(
-    ({ app }) => {
-      if (app.predeploy?.[0]?.run) {
-        return app.predeploy[0].run.value.length > 0;
-      }
-      return true;
-    },
-    {
-      message: "if using a pre-deploy job, its start command must be non-empty",
-      path: ["app", "services"],
-    }
-  )
   .refine(
     ({ app, source }) => {
       if (source.type !== "docker-registry" && app.build.method === "pack") {
@@ -320,7 +308,10 @@ export function clientAppToProto(data: PorterAppFormData): PorterApp {
     return acc;
   }, {});
 
-  const predeploy = app.predeploy?.[0];
+  // filter out predeploy if its start command is empty
+  const predeploy = app.predeploy?.[0]?.run.value
+    ? app.predeploy[0]
+    : undefined;
 
   const proto = match(source)
     .with(

+ 2 - 11
dashboard/src/main/home/Home.tsx

@@ -1,5 +1,4 @@
 import React, { useContext, useEffect, useRef, useState } from "react";
-import { useStripe } from "@stripe/react-stripe-js";
 import { createPortal } from "react-dom";
 import {
   Route,
@@ -14,7 +13,6 @@ import Loading from "components/Loading";
 import NoClusterPlaceHolder from "components/NoClusterPlaceHolder";
 import Button from "components/porter/Button";
 import Modal from "components/porter/Modal";
-import ShowIntercomButton from "components/porter/ShowIntercomButton";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 
@@ -31,10 +29,7 @@ import {
   type ProjectListType,
   type ProjectType,
 } from "shared/types";
-import { overrideInfraTabEnabled } from "utils/infrastructure";
 
-import discordLogo from "../../assets/discord.svg";
-import warning from "../../assets/warning.svg";
 import AddOnDashboard from "./add-on-dashboard/AddOnDashboard";
 import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
 import AppView from "./app-dashboard/app-view/AppView";
@@ -48,9 +43,9 @@ import PreviewEnvs from "./cluster-dashboard/preview-environments/v2/PreviewEnvs
 import SetupApp from "./cluster-dashboard/preview-environments/v2/setup-app/SetupApp";
 import ComplianceDashboard from "./compliance-dashboard/ComplianceDashboard";
 import Dashboard from "./dashboard/Dashboard";
-import CreateDatabase from "./database-dashboard/CreateDatabase";
 import DatabaseDashboard from "./database-dashboard/DatabaseDashboard";
 import DatabaseView from "./database-dashboard/DatabaseView";
+import CreateDatastore from "./database-dashboard/forms/CreateDatastore";
 import CreateEnvGroup from "./env-dashboard/CreateEnvGroup";
 import EnvDashboard from "./env-dashboard/EnvDashboard";
 import ExpandedEnv from "./env-dashboard/ExpandedEnv";
@@ -450,12 +445,8 @@ const Home: React.FC<Props> = (props) => {
               <Route path="/environment-groups">
                 <EnvDashboard />
               </Route>
-
-              <Route path="/datastores/new/:type/:engine">
-                <CreateDatabase />
-              </Route>
               <Route path="/datastores/new">
-                <CreateDatabase />
+                <CreateDatastore />
               </Route>
               <Route path="/datastores/:datastoreName/:tab">
                 <DatabaseView />

+ 2 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/metrics/types.ts

@@ -101,4 +101,5 @@ export type AvailableMetrics =
   | "nginx:status"
   | "cpu_hpa_threshold"
   | "memory_hpa_threshold"
-  | "hpa_replicas";
+  | "hpa_replicas"
+  | "replicas";

+ 0 - 185
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -1,185 +0,0 @@
-import React, { useMemo } from "react";
-import _ from "lodash";
-import { useHistory, useLocation, withRouter } from "react-router";
-import styled from "styled-components";
-import { match } from "ts-pattern";
-
-import Back from "components/porter/Back";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import {
-  DATASTORE_ENGINE_AURORA_POSTGRES,
-  DATASTORE_ENGINE_POSTGRES,
-  DATASTORE_ENGINE_REDIS,
-  DATASTORE_TYPE_ELASTICACHE,
-  DATASTORE_TYPE_RDS,
-  type DatastoreTemplate,
-} from "lib/databases/types";
-
-import databaseGrad from "assets/database-grad.svg";
-
-import DashboardHeader from "../cluster-dashboard/DashboardHeader";
-import { SUPPORTED_DATASTORE_TEMPLATES } from "./constants";
-import DatabaseFormAuroraPostgres from "./forms/DatabaseFormAuroraPostgres";
-import DatabaseFormElasticacheRedis from "./forms/DatabaseFormElasticacheRedis";
-import DatabaseFormRDSPostgres from "./forms/DatabaseFormRDSPostgres";
-import EngineTag from "./tags/EngineTag";
-
-const CreateDatabase: React.FC = () => {
-  const { search } = useLocation();
-  const history = useHistory();
-  const queryParams = new URLSearchParams(search);
-
-  const templateMatch: DatastoreTemplate | undefined = useMemo(() => {
-    return SUPPORTED_DATASTORE_TEMPLATES.find(
-      (t) =>
-        !t.disabled &&
-        t.type.name === queryParams.get("type") &&
-        t.engine.name === queryParams.get("engine")
-    );
-  }, [queryParams]);
-
-  return (
-    <StyledTemplateComponent>
-      {match(templateMatch)
-        .with(
-          { type: DATASTORE_TYPE_RDS, engine: DATASTORE_ENGINE_POSTGRES },
-          (t) => <DatabaseFormRDSPostgres template={t} />
-        )
-        .with(
-          {
-            type: DATASTORE_TYPE_RDS,
-            engine: DATASTORE_ENGINE_AURORA_POSTGRES,
-          },
-          (t) => <DatabaseFormAuroraPostgres template={t} />
-        )
-        .with(
-          { type: DATASTORE_TYPE_ELASTICACHE, engine: DATASTORE_ENGINE_REDIS },
-          (t) => <DatabaseFormElasticacheRedis template={t} />
-        )
-        .otherwise(() => (
-          <>
-            <Back to="/datastores" />
-            <DashboardHeader
-              image={databaseGrad}
-              title="Create a new datastore"
-              capitalize={false}
-              disableLineBreak
-            />
-            <Text size={15}>Production datastores</Text>
-            <Spacer y={0.5} />
-            <Text color="helper">
-              Fully-managed production-ready datastores.
-            </Text>
-            <Spacer y={0.5} />
-            <TemplateListWrapper>
-              {SUPPORTED_DATASTORE_TEMPLATES.map((template) => {
-                const { name, icon, description, disabled, engine, type } =
-                  template;
-                return (
-                  <TemplateBlock
-                    disabled={disabled}
-                    key={`${name}-${engine.name}`}
-                    onClick={() => {
-                      const query = new URLSearchParams();
-                      query.set("type", type.name);
-                      query.set("engine", engine.name);
-                      history.push(`/datastores/new?${query.toString()}`);
-                    }}
-                  >
-                    <TemplateHeader>
-                      <Icon src={icon} />
-                      <Spacer inline x={0.5} />
-                      <TemplateTitle>{name}</TemplateTitle>
-                      <Spacer inline x={0.5} />
-                    </TemplateHeader>
-                    <Spacer y={0.5} />
-                    <EngineTag engine={engine} />
-                    <Spacer y={0.5} />
-                    <TemplateDescription>{description}</TemplateDescription>
-                    <Spacer y={0.5} />
-                  </TemplateBlock>
-                );
-              })}
-            </TemplateListWrapper>
-          </>
-        ))}
-    </StyledTemplateComponent>
-  );
-};
-
-export default withRouter(CreateDatabase);
-
-const Icon = styled.img`
-  height: 18px;
-`;
-
-const StyledTemplateComponent = styled.div`
-  width: 100%;
-  height: 100%;
-`;
-
-const TemplateDescription = styled.div`
-  display: flex;
-  margin-bottom: 15px;
-  color: #ffffff66;
-  font-weight: default;
-  padding: 0px 50px;
-  line-height: 1.4;
-  font-size: 14px;
-  text-align: center;
-`;
-
-const TemplateHeader = styled.div`
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  margin-top: 50px;
-`;
-
-const TemplateTitle = styled.div`
-  display: flex;
-  width: 100%;
-  justify-content: center;
-  font-size: 22px;
-  white-space: nowrap;
-`;
-
-const TemplateBlock = styled.div<{ disabled?: boolean }>`
-  align-items: center;
-  user-select: none;
-  display: flex;
-  filter: ${({ disabled }) => (disabled ? "grayscale(1)" : "")};
-  cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
-  font-size: 13px;
-  flex-direction: column;
-  align-item: center;
-  height: 220px;
-  color: #ffffff;
-  position: relative;
-  border-radius: 5px;
-  background: ${(props) => props.theme.clickable.bg};
-  border: 1px solid #494b4f;
-  :hover {
-    border: ${(props) => (props.disabled ? "" : "1px solid #7a7b80")};
-  }
-
-  animation: fadeIn 0.3s 0s;
-  @keyframes fadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-`;
-
-const TemplateListWrapper = styled.div`
-  overflow: visible;
-  margin-top: 15px;
-  display: grid;
-  grid-column-gap: 30px;
-  grid-row-gap: 30px;
-  grid-template-columns: repeat(2, 1fr);
-`;

+ 131 - 0
dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx

@@ -0,0 +1,131 @@
+import React, { createContext, useMemo, useState } from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { FormProvider, useForm } from "react-hook-form";
+import { useHistory } from "react-router";
+import styled from "styled-components";
+
+import { Error as ErrorComponent } from "components/porter/Error";
+import { dbFormValidator, type DbFormData } from "lib/databases/types";
+import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
+import { useDatastoreList } from "lib/hooks/useDatabaseList";
+import { useDatastore } from "lib/hooks/useDatastore";
+import { useIntercom } from "lib/hooks/useIntercom";
+
+// todo(ianedwards): refactor button to use more predictable state
+export type UpdateDatastoreButtonProps = {
+  status: "" | "loading" | JSX.Element | "success";
+  isDisabled: boolean;
+  loadingText?: string;
+};
+
+type DatastoreFormContextType = {
+  updateDatastoreButtonProps: UpdateDatastoreButtonProps;
+};
+
+const DatastoreFormContext = createContext<DatastoreFormContextType | null>(
+  null
+);
+
+export const useDatastoreFormContext = (): DatastoreFormContextType => {
+  const ctx = React.useContext(DatastoreFormContext);
+  if (!ctx) {
+    throw new Error(
+      "useDatastoreFormContext must be used within a DatastoreFormContextProvider"
+    );
+  }
+  return ctx;
+};
+
+type DatastoreFormContextProviderProps = {
+  children: JSX.Element;
+};
+const DatastoreFormContextProvider: React.FC<
+  DatastoreFormContextProviderProps
+> = ({ children }) => {
+  const [updateDatastoreError, setUpdateDatastoreError] = useState<string>("");
+
+  const { showIntercomWithMessage } = useIntercom();
+
+  const { datastores: existingDatastores } = useDatastoreList();
+  const { create: createDatastore } = useDatastore();
+  const history = useHistory();
+
+  const datastoreForm = useForm<DbFormData>({
+    resolver: zodResolver(dbFormValidator),
+    reValidateMode: "onSubmit",
+  });
+  const {
+    handleSubmit,
+    formState: { isSubmitting, errors },
+  } = datastoreForm;
+
+  const updateDatastoreButtonProps = useMemo(() => {
+    const props: UpdateDatastoreButtonProps = {
+      status: "",
+      isDisabled: false,
+      loadingText: "",
+    };
+    if (isSubmitting) {
+      props.status = "loading";
+      props.isDisabled = true;
+      props.loadingText = "Creating datastore...";
+    }
+
+    if (updateDatastoreError) {
+      props.status = (
+        <ErrorComponent message={updateDatastoreError} maxWidth="600px" />
+      );
+    }
+    if (Object.keys(errors).length > 0) {
+      // TODO: remove this and properly handle form validation errors
+      console.log("errors", errors);
+    }
+
+    return props;
+  }, [isSubmitting, updateDatastoreError, errors]);
+
+  const onSubmit = handleSubmit(async (data) => {
+    setUpdateDatastoreError("");
+    if (existingDatastores.some((db) => db.name === data.name)) {
+      setUpdateDatastoreError(
+        "A datastore with this name already exists. Please choose a different name."
+      );
+      return;
+    }
+    try {
+      await createDatastore(data);
+      history.push(`/datastores/${data.name}`);
+    } catch (err) {
+      const errorMessage = getErrorMessageFromNetworkCall(
+        err,
+        "Datastore creation"
+      );
+      setUpdateDatastoreError(errorMessage);
+      showIntercomWithMessage({
+        message: "I am having trouble creating a datastore.",
+      });
+    }
+  });
+
+  return (
+    <DatastoreFormContext.Provider
+      value={{
+        updateDatastoreButtonProps,
+      }}
+    >
+      <Wrapper>
+        <FormProvider {...datastoreForm}>
+          <form onSubmit={onSubmit}>{children}</form>
+        </FormProvider>
+      </Wrapper>
+    </DatastoreFormContext.Provider>
+  );
+};
+
+export default DatastoreFormContextProvider;
+
+const Wrapper = styled.div`
+  height: fit-content;
+  margin-bottom: 10px;
+  width: 100%;
+`;

+ 205 - 107
dashboard/src/main/home/database-dashboard/constants.ts

@@ -1,8 +1,4 @@
 import {
-  DATASTORE_ENGINE_AURORA_POSTGRES,
-  DATASTORE_ENGINE_MEMCACHED,
-  DATASTORE_ENGINE_POSTGRES,
-  DATASTORE_ENGINE_REDIS,
   DATASTORE_STATE_AVAILABLE,
   DATASTORE_STATE_AWAITING_DELETION,
   DATASTORE_STATE_BACKING_UP,
@@ -15,104 +11,228 @@ import {
   DATASTORE_STATE_DELETING_REPLICATION_GROUP,
   DATASTORE_STATE_MODIFYING,
   DATASTORE_TYPE_ELASTICACHE,
+  DATASTORE_TYPE_MANAGED_POSTGRES,
+  DATASTORE_TYPE_MANAGED_REDIS,
   DATASTORE_TYPE_RDS,
+  type DatastoreEngine,
   type DatastoreTemplate,
 } from "lib/databases/types";
 
 import awsRDS from "assets/amazon-rds.png";
 import awsElastiCache from "assets/aws-elasticache.png";
+import infra from "assets/cluster.svg";
+import postgresql from "assets/postgresql.svg";
+import redis from "assets/redis.svg";
 
-export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
+export const DATASTORE_ENGINE_POSTGRES: DatastoreEngine = {
+  name: "POSTGRES" as const,
+  displayName: "PostgreSQL",
+  icon: postgresql as string,
+};
+export const DATASTORE_ENGINE_AURORA_POSTGRES: DatastoreEngine = {
+  name: "AURORA-POSTGRES" as const,
+  displayName: "Aurora PostgreSQL",
+  icon: postgresql as string,
+};
+export const DATASTORE_ENGINE_REDIS: DatastoreEngine = {
+  name: "REDIS" as const,
+  displayName: "Redis",
+  icon: redis as string,
+};
+export const DATASTORE_ENGINE_MEMCACHED: DatastoreEngine = {
+  name: "MEMCACHED" as const,
+  displayName: "Memcached",
+  icon: redis as string,
+};
+
+export const DATASTORE_TEMPLATE_AWS_RDS: DatastoreTemplate = Object.freeze({
+  name: "Amazon RDS",
+  displayName: "Amazon RDS",
+  highLevelType: DATASTORE_ENGINE_POSTGRES,
+  type: DATASTORE_TYPE_RDS,
+  engine: DATASTORE_ENGINE_POSTGRES,
+  supportedEngineVersions: [
+    { name: "15.4" as const, displayName: "PostgreSQL 15.4" },
+    { name: "14.11" as const, displayName: "PostgreSQL 14.11" },
+  ],
+  icon: awsRDS as string,
+  description:
+    "Amazon Relational Database Service (RDS) is a web service that makes it easier to set up, operate, and scale a relational database in the cloud.",
+  disabled: false,
+  instanceTiers: [
+    {
+      tier: "db.t4g.micro" as const,
+      label: "Micro",
+      cpuCores: 2,
+      ramGigabytes: 1,
+      storageGigabytes: 20,
+    },
+    {
+      tier: "db.t4g.small" as const,
+      label: "Small",
+      cpuCores: 2,
+      ramGigabytes: 2,
+      storageGigabytes: 30,
+    },
+    {
+      tier: "db.t4g.medium" as const,
+      label: "Medium",
+      cpuCores: 2,
+      ramGigabytes: 4,
+      storageGigabytes: 100,
+    },
+    {
+      tier: "db.t4g.large" as const,
+      label: "Large",
+      cpuCores: 2,
+      ramGigabytes: 8,
+      storageGigabytes: 256,
+    },
+    {
+      tier: "db.m6g.large" as const,
+      label: "Large (High Performance)",
+      cpuCores: 2,
+      ramGigabytes: 8,
+      storageGigabytes: 512,
+    },
+  ],
+  formTitle: "Create an RDS PostgreSQL instance",
+  creationStateProgression: [
+    DATASTORE_STATE_CREATING,
+    DATASTORE_STATE_CONFIGURING_LOG_EXPORTS,
+    DATASTORE_STATE_MODIFYING,
+    DATASTORE_STATE_CONFIGURING_ENHANCED_MONITORING,
+    DATASTORE_STATE_BACKING_UP,
+    DATASTORE_STATE_AVAILABLE,
+  ],
+  deletionStateProgression: [
+    DATASTORE_STATE_AWAITING_DELETION,
+    DATASTORE_STATE_DELETING_RECORD,
+    DATASTORE_STATE_DELETED,
+  ],
+});
+export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({
+  name: "Amazon Aurora",
+  displayName: "Amazon Aurora PostgreSQL",
+  highLevelType: DATASTORE_ENGINE_POSTGRES,
+  type: DATASTORE_TYPE_RDS,
+  engine: DATASTORE_ENGINE_AURORA_POSTGRES,
+  supportedEngineVersions: [],
+  icon: awsRDS as string,
+  description:
+    "Amazon Aurora PostgreSQL is an ACID–compliant relational database engine that combines the speed, reliability, and manageability of Amazon Aurora with the simplicity and cost-effectiveness of open-source databases.",
+  disabled: false,
+  instanceTiers: [
+    {
+      tier: "db.t4g.medium" as const,
+      label: "Medium",
+      cpuCores: 2,
+      ramGigabytes: 4,
+      storageGigabytes: 100,
+    },
+    {
+      tier: "db.t4g.large" as const,
+      label: "Large",
+      cpuCores: 4,
+      ramGigabytes: 8,
+      storageGigabytes: 256,
+    },
+  ],
+  formTitle: "Create an Aurora PostgreSQL instance",
+  creationStateProgression: [
+    DATASTORE_STATE_CREATING,
+    DATASTORE_STATE_AVAILABLE,
+  ],
+  deletionStateProgression: [
+    DATASTORE_STATE_AWAITING_DELETION,
+    DATASTORE_STATE_DELETING_RECORD,
+    DATASTORE_STATE_DELETED,
+  ],
+});
+export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate =
   Object.freeze({
-    name: "Amazon RDS",
-    type: DATASTORE_TYPE_RDS,
-    engine: DATASTORE_ENGINE_POSTGRES,
-    supportedEngineVersions: [
-      { name: "15.4" as const, displayName: "PostgreSQL 15.4" },
-      { name: "14.11" as const, displayName: "PostgreSQL 14.11" },
-    ],
-    icon: awsRDS as string,
+    name: "Amazon ElastiCache",
+    displayName: "Amazon ElastiCache Redis",
+    highLevelType: DATASTORE_ENGINE_REDIS,
+    type: DATASTORE_TYPE_ELASTICACHE,
+    engine: DATASTORE_ENGINE_REDIS,
+    supportedEngineVersions: [],
+    icon: awsElastiCache as string,
     description:
-      "Amazon Relational Database Service (RDS) is a web service that makes it easier to set up, operate, and scale a relational database in the cloud.",
+      "Amazon ElastiCache is a web service that makes it easy to deploy, operate, and scale an in-memory data store or cache in the cloud.",
     disabled: false,
     instanceTiers: [
       {
-        tier: "db.t4g.micro" as const,
+        tier: "cache.t4g.micro" as const,
         label: "Micro",
         cpuCores: 2,
-        ramGigabytes: 1,
-        storageGigabytes: 20,
-      },
-      {
-        tier: "db.t4g.small" as const,
-        label: "Small",
-        cpuCores: 2,
-        ramGigabytes: 2,
-        storageGigabytes: 30,
+        ramGigabytes: 0.5,
+        storageGigabytes: 0,
       },
       {
-        tier: "db.t4g.medium" as const,
+        tier: "cache.t4g.medium" as const,
         label: "Medium",
         cpuCores: 2,
-        ramGigabytes: 4,
-        storageGigabytes: 100,
+        ramGigabytes: 3,
+        storageGigabytes: 0,
       },
       {
-        tier: "db.t4g.large" as const,
+        tier: "cache.r6g.large" as const,
         label: "Large",
         cpuCores: 2,
-        ramGigabytes: 8,
-        storageGigabytes: 256,
+        ramGigabytes: 13,
+        storageGigabytes: 0,
       },
       {
-        tier: "db.m6g.large" as const,
-        label: "Large (High Performance)",
-        cpuCores: 2,
-        ramGigabytes: 8,
-        storageGigabytes: 512,
+        tier: "cache.r6g.xlarge" as const,
+        label: "Extra Large",
+        cpuCores: 4,
+        ramGigabytes: 26,
+        storageGigabytes: 0,
       },
     ],
-    formTitle: "Create an RDS PostgreSQL instance",
+    formTitle: "Create an ElastiCache Redis instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
-      DATASTORE_STATE_CONFIGURING_LOG_EXPORTS,
       DATASTORE_STATE_MODIFYING,
-      DATASTORE_STATE_CONFIGURING_ENHANCED_MONITORING,
-      DATASTORE_STATE_BACKING_UP,
       DATASTORE_STATE_AVAILABLE,
     ],
     deletionStateProgression: [
       DATASTORE_STATE_AWAITING_DELETION,
+      DATASTORE_STATE_DELETING_REPLICATION_GROUP,
+      DATASTORE_STATE_DELETING_PARAMETER_GROUP,
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
-  }),
+  });
+export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate =
   Object.freeze({
-    name: "Amazon Aurora",
-    type: DATASTORE_TYPE_RDS,
-    engine: DATASTORE_ENGINE_AURORA_POSTGRES,
+    name: "Managed Redis",
+    displayName: "Cluster-managed Redis",
+    highLevelType: DATASTORE_ENGINE_REDIS,
+    type: DATASTORE_TYPE_MANAGED_REDIS,
+    engine: DATASTORE_ENGINE_REDIS,
     supportedEngineVersions: [],
-    icon: awsRDS as string,
-    description:
-      "Amazon Aurora PostgreSQL is an ACID–compliant relational database engine that combines the speed, reliability, and manageability of Amazon Aurora with the simplicity and cost-effectiveness of open-source databases.",
-    disabled: false,
+    icon: infra as string,
+    description: "A redis cluster hosted on your Porter cluster.",
+    disabled: true,
     instanceTiers: [
       {
-        tier: "db.t4g.medium" as const,
-        label: "Medium",
-        cpuCores: 2,
-        ramGigabytes: 4,
-        storageGigabytes: 100,
+        tier: "db.t4g.micro" as const,
+        label: "Micro",
+        cpuCores: 1,
+        ramGigabytes: 1,
+        storageGigabytes: 1,
       },
       {
-        tier: "db.t4g.large" as const,
-        label: "Large",
-        cpuCores: 4,
-        ramGigabytes: 8,
-        storageGigabytes: 256,
+        tier: "db.t4g.small" as const,
+        label: "Small",
+        cpuCores: 2,
+        ramGigabytes: 2,
+        storageGigabytes: 2,
       },
     ],
-    formTitle: "Create an Aurora PostgreSQL instance",
+    formTitle: "Create an ElastiCache Memcached instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
       DATASTORE_STATE_AVAILABLE,
@@ -122,72 +242,50 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
-  }),
+  });
+export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate =
   Object.freeze({
-    name: "Amazon ElastiCache",
-    type: DATASTORE_TYPE_ELASTICACHE,
-    engine: DATASTORE_ENGINE_REDIS,
+    name: "Managed PostgreSQL",
+    displayName: "Cluster-managed PostgreSQL",
+    highLevelType: DATASTORE_ENGINE_POSTGRES,
+    type: DATASTORE_TYPE_MANAGED_POSTGRES,
+    engine: DATASTORE_ENGINE_POSTGRES,
     supportedEngineVersions: [],
-    icon: awsElastiCache as string,
-    description:
-      "Amazon ElastiCache is a web service that makes it easy to deploy, operate, and scale an in-memory data store or cache in the cloud.",
-    disabled: false,
+    icon: infra as string,
+    description: "A postgresql instance hosted on your Porter cluster.",
+    disabled: true,
     instanceTiers: [
       {
-        tier: "cache.t4g.micro" as const,
+        tier: "db.t4g.micro" as const,
         label: "Micro",
-        cpuCores: 2,
-        ramGigabytes: 0.5,
-        storageGigabytes: 0,
-      },
-      {
-        tier: "cache.t4g.medium" as const,
-        label: "Medium",
-        cpuCores: 2,
-        ramGigabytes: 3,
-        storageGigabytes: 0,
+        cpuCores: 1,
+        ramGigabytes: 1,
+        storageGigabytes: 1,
       },
       {
-        tier: "cache.r6g.large" as const,
-        label: "Large",
+        tier: "db.t4g.small" as const,
+        label: "Small",
         cpuCores: 2,
-        ramGigabytes: 13,
-        storageGigabytes: 0,
-      },
-      {
-        tier: "cache.r6g.xlarge" as const,
-        label: "Extra Large",
-        cpuCores: 4,
-        ramGigabytes: 26,
-        storageGigabytes: 0,
+        ramGigabytes: 2,
+        storageGigabytes: 2,
       },
     ],
-    formTitle: "Create an ElastiCache Redis instance",
+    formTitle: "Create a managed PostgreSQL instance",
     creationStateProgression: [
       DATASTORE_STATE_CREATING,
-      DATASTORE_STATE_MODIFYING,
       DATASTORE_STATE_AVAILABLE,
     ],
     deletionStateProgression: [
       DATASTORE_STATE_AWAITING_DELETION,
-      DATASTORE_STATE_DELETING_REPLICATION_GROUP,
-      DATASTORE_STATE_DELETING_PARAMETER_GROUP,
       DATASTORE_STATE_DELETING_RECORD,
       DATASTORE_STATE_DELETED,
     ],
-  }),
-  Object.freeze({
-    name: "Amazon ElastiCache",
-    type: DATASTORE_TYPE_ELASTICACHE,
-    engine: DATASTORE_ENGINE_MEMCACHED,
-    supportedEngineVersions: [],
-    icon: awsElastiCache as string,
-    description:
-      "Currently unavailable. Please contact support@porter.run for more details.",
-    disabled: true,
-    instanceTiers: [],
-    formTitle: "Create an ElastiCache Memcached instance",
-    creationStateProgression: [],
-    deletionStateProgression: [],
-  }),
+  });
+
+export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
+  DATASTORE_TEMPLATE_AWS_RDS,
+  DATASTORE_TEMPLATE_AWS_AURORA,
+  DATASTORE_TEMPLATE_AWS_ELASTICACHE,
+  DATASTORE_TEMPLATE_MANAGED_POSTGRES,
+  DATASTORE_TEMPLATE_MANAGED_REDIS,
 ];

+ 14 - 0
dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx

@@ -0,0 +1,14 @@
+import React from "react";
+
+import DatastoreFormContextProvider from "../DatastoreFormContextProvider";
+import DatastoreForm from "./DatastoreForm";
+
+const CreateDatastore: React.FC = () => {
+  return (
+    <DatastoreFormContextProvider>
+      <DatastoreForm />
+    </DatastoreFormContextProvider>
+  );
+};
+
+export default CreateDatastore;

+ 0 - 236
dashboard/src/main/home/database-dashboard/forms/DatabaseForm.tsx

@@ -1,236 +0,0 @@
-import React, { useContext, useEffect, useMemo, useState } from "react";
-import axios from "axios";
-import _ from "lodash";
-import { FormProvider, type UseFormReturn } from "react-hook-form";
-import { withRouter, type RouteComponentProps } from "react-router";
-import styled, { keyframes } from "styled-components";
-
-import Button from "components/porter/Button";
-import { ControlledInput } from "components/porter/ControlledInput";
-import Error from "components/porter/Error";
-import Selector from "components/porter/Selector";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import VerticalSteps from "components/porter/VerticalSteps";
-import { isAWSCluster } from "lib/clusters/types";
-import { type DbFormData } from "lib/databases/types";
-import { useClusterList } from "lib/hooks/useCluster";
-import { useDatastoreList } from "lib/hooks/useDatabaseList";
-import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
-import { useIntercom } from "lib/hooks/useIntercom";
-
-import { Context } from "shared/Context";
-
-type Props = RouteComponentProps & {
-  steps: React.ReactNode[];
-  currentStep: number;
-  form: UseFormReturn<DbFormData>;
-};
-
-const DatabaseForm: React.FC<Props> = ({
-  steps,
-  currentStep,
-  form,
-  history,
-}) => {
-  const [submitErrorMessage, setSubmitErrorMessage] = useState<string>("");
-  const { create: createDatastore } = useDatastoreMethods();
-  const { showIntercomWithMessage } = useIntercom();
-  const { clusters } = useClusterList();
-  const { currentProject } = useContext(Context);
-
-  // only aws clusters supported right now
-  const awsClusters = useMemo(() => {
-    return clusters.filter(isAWSCluster);
-  }, [JSON.stringify(clusters)]);
-
-  const {
-    formState: { isSubmitting, errors, isValidating },
-    handleSubmit,
-    register,
-    setValue,
-    watch,
-  } = form;
-
-  const { datastores: existingDatastores } = useDatastoreList();
-
-  const chosenClusterId = watch("clusterId", 0);
-
-  const onSubmit = handleSubmit(async (data) => {
-    setSubmitErrorMessage("");
-    if (existingDatastores.some((db) => db.name === data.name)) {
-      setSubmitErrorMessage(
-        "A datastore with this name already exists. Please choose a different name."
-      );
-      return;
-    }
-    try {
-      await createDatastore(data);
-      history.push(`/datastores/${data.name}`);
-    } catch (err) {
-      const errorMessage =
-        axios.isAxiosError(err) && err.response?.data?.error
-          ? err.response.data.error
-          : "An error occurred while creating your datastore. Please try again.";
-      setSubmitErrorMessage(errorMessage);
-      showIntercomWithMessage({
-        message: "I am having trouble creating a datastore.",
-      });
-    }
-  });
-
-  const submitButtonStatus = useMemo(() => {
-    if (isSubmitting || isValidating) {
-      return "loading";
-    }
-    if (submitErrorMessage) {
-      return <Error message={submitErrorMessage} />;
-    }
-    return undefined;
-  }, [isSubmitting, submitErrorMessage, isValidating]);
-
-  useEffect(() => {
-    if (awsClusters.length > 0) {
-      setValue("clusterId", awsClusters[0].id);
-    }
-  }, [JSON.stringify(awsClusters)]);
-
-  return (
-    <FormProvider {...form}>
-      <form onSubmit={onSubmit}>
-        <VerticalSteps
-          currentStep={currentStep}
-          steps={[
-            <>
-              <Text size={16}>Specify name</Text>
-              <Spacer y={0.5} />
-              <Text color="helper">
-                Lowercase letters, numbers, and &quot;-&quot; only.
-              </Text>
-              <Spacer height="20px" />
-              <ControlledInput
-                placeholder="ex: academic-sophon-db"
-                type="text"
-                width="300px"
-                error={errors.name?.message}
-                {...register("name")}
-              />
-              {currentProject?.multi_cluster && (
-                <>
-                  <Spacer y={1} />
-                  <Selector<string>
-                    activeValue={chosenClusterId.toString()}
-                    width="300px"
-                    options={awsClusters.map((c) => ({
-                      value: c.id.toString(),
-                      label: c.vanity_name,
-                      key: c.id.toString(),
-                    }))}
-                    setActiveValue={(value: string) => {
-                      setValue("clusterId", parseInt(value));
-                    }}
-                    label={"Cluster"}
-                  />
-                </>
-              )}
-            </>,
-            ...steps,
-            <>
-              <Text size={16}>Create datastore instance</Text>
-              <Spacer y={0.5} />
-              <Button
-                type="submit"
-                status={submitButtonStatus}
-                loadingText={"Creating . . ."}
-                disabled={isSubmitting || isValidating}
-              >
-                Create
-              </Button>
-            </>,
-          ]}
-        />
-        <Spacer height="80px" />
-      </form>
-    </FormProvider>
-  );
-};
-
-export default withRouter(DatabaseForm);
-
-export const Div = styled.div`
-  width: 100%;
-  max-width: 900px;
-`;
-
-export const CenterWrapper = styled.div`
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-`;
-
-export const DarkMatter = styled.div`
-  width: 100%;
-  margin-top: -5px;
-`;
-
-export const Icon = styled.img`
-  margin-right: 15px;
-  height: 30px;
-  animation: floatIn 0.5s;
-  animation-fill-mode: forwards;
-
-  @keyframes floatIn {
-    from {
-      opacity: 0;
-      transform: translateY(20px);
-    }
-    to {
-      opacity: 1;
-      transform: translateY(0px);
-    }
-  }
-`;
-
-export const StyledConfigureTemplate = styled.div`
-  height: 100%;
-`;
-
-export const floatIn = keyframes`
-  0% {
-    opacity: 0;
-    transform: translateY(10px);
-  }
-  100% {
-    opacity: 1;
-    transform: translateY(0px);
-  }
-`;
-
-export const AppearingErrorContainer = styled.div`
-  animation: ${floatIn} 0.5s;
-  animation-fill-mode: forwards;
-`;
-
-export const RevealButton = styled.div`
-  background: ${(props) => props.theme.fg};
-  padding: 5px 10px;
-  border-radius: 5px;
-  border: 1px solid #494b4f;
-  cursor: pointer;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  :hover {
-    filter: brightness(120%);
-  }
-`;
-
-export const Blur = styled.div`
-  filter: blur(5px);
-  -webkit-filter: blur(5px);
-  position: relative;
-  margin-left: -5px;
-  font-family: monospace;
-`;

+ 0 - 136
dashboard/src/main/home/database-dashboard/forms/DatabaseFormAuroraPostgres.tsx

@@ -1,136 +0,0 @@
-import React from "react";
-import { zodResolver } from "@hookform/resolvers/zod";
-import _ from "lodash";
-import { useForm } from "react-hook-form";
-import { withRouter, type RouteComponentProps } from "react-router";
-import { v4 as uuidv4 } from "uuid";
-
-import Back from "components/porter/Back";
-import Error from "components/porter/Error";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import {
-  dbFormValidator,
-  type DatastoreTemplate,
-  type DbFormData,
-  type ResourceOption,
-} from "lib/databases/types";
-
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import ConnectionInfo from "../shared/ConnectionInfo";
-import Resources from "../shared/Resources";
-import DatabaseForm, {
-  AppearingErrorContainer,
-  CenterWrapper,
-  DarkMatter,
-  Div,
-  Icon,
-  StyledConfigureTemplate,
-} from "./DatabaseForm";
-
-type Props = RouteComponentProps & {
-  template: DatastoreTemplate;
-};
-
-const DatabaseFormAuroraPostgres: React.FC<Props> = ({ history, template }) => {
-  const dbForm = useForm<DbFormData>({
-    resolver: zodResolver(dbFormValidator),
-    reValidateMode: "onSubmit",
-    defaultValues: {
-      config: {
-        type: "rds-postgresql-aurora",
-        databaseName: "postgres",
-        masterUsername: "postgres",
-        masterUserPassword: uuidv4(),
-      },
-    },
-  });
-
-  const {
-    setValue,
-    formState: { errors },
-    watch,
-  } = dbForm;
-
-  const watchTier = watch("config.instanceClass", "unspecified");
-
-  const watchDbName = watch("config.databaseName");
-  const watchDbUsername = watch("config.masterUsername");
-  const watchDbPassword = watch("config.masterUserPassword");
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back
-            onClick={() => {
-              history.push(`/datastores/new`);
-            }}
-          />
-          <DashboardHeader
-            prefix={<Icon src={template.icon} />}
-            title={template.formTitle}
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <DatabaseForm
-            steps={[
-              <>
-                <Text size={16}>Specify resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Specify your database CPU, RAM, and storage.
-                </Text>
-                {errors.config?.instanceClass?.message && (
-                  <AppearingErrorContainer>
-                    <Spacer y={0.5} />
-                    <Error message={errors.config.instanceClass.message} />
-                  </AppearingErrorContainer>
-                )}
-                <Spacer y={0.5} />
-                <Text>Select an instance tier:</Text>
-                <Spacer height="20px" />
-                <Resources
-                  options={template.instanceTiers}
-                  selected={watchTier}
-                  onSelect={(option: ResourceOption) => {
-                    setValue("config.instanceClass", option.tier);
-                    setValue(
-                      "config.allocatedStorageGigabytes",
-                      option.storageGigabytes
-                    );
-                  }}
-                  highlight={"storage"}
-                />
-              </>,
-              <>
-                <Text size={16}>Credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. Your
-                  app will use them to connect to this datastore.
-                </Text>
-                <Spacer height="20px" />
-                <ConnectionInfo
-                  connectionInfo={{
-                    host: "(determined after creation)",
-                    port: 5432,
-                    password: watchDbPassword,
-                    username: watchDbUsername,
-                    database_name: watchDbName,
-                  }}
-                  type={template.type}
-                />
-              </>,
-            ]}
-            currentStep={100}
-            form={dbForm}
-          />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(DatabaseFormAuroraPostgres);

+ 0 - 135
dashboard/src/main/home/database-dashboard/forms/DatabaseFormElasticacheRedis.tsx

@@ -1,135 +0,0 @@
-import React from "react";
-import { zodResolver } from "@hookform/resolvers/zod";
-import _ from "lodash";
-import { useForm } from "react-hook-form";
-import { withRouter, type RouteComponentProps } from "react-router";
-import { v4 as uuidv4 } from "uuid";
-
-import Back from "components/porter/Back";
-import Error from "components/porter/Error";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import {
-  dbFormValidator,
-  type DatastoreTemplate,
-  type DbFormData,
-  type ResourceOption,
-} from "lib/databases/types";
-
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import ConnectionInfo from "../shared/ConnectionInfo";
-import Resources from "../shared/Resources";
-import DatabaseForm, {
-  AppearingErrorContainer,
-  CenterWrapper,
-  DarkMatter,
-  Div,
-  Icon,
-  StyledConfigureTemplate,
-} from "./DatabaseForm";
-
-type Props = RouteComponentProps & {
-  template: DatastoreTemplate;
-};
-
-const DatabaseFormElasticacheRedis: React.FC<Props> = ({
-  history,
-  template,
-}) => {
-  const dbForm = useForm<DbFormData>({
-    resolver: zodResolver(dbFormValidator),
-    reValidateMode: "onSubmit",
-    defaultValues: {
-      config: {
-        type: "elasticache-redis",
-        databaseName: "postgres",
-        masterUsername: "postgres",
-        masterUserPassword: uuidv4(),
-      },
-    },
-  });
-
-  const {
-    setValue,
-    formState: { errors },
-    watch,
-  } = dbForm;
-
-  const watchTier = watch("config.instanceClass", "unspecified");
-
-  const watchDbPassword = watch("config.masterUserPassword");
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back
-            onClick={() => {
-              history.push(`/datastores/new`);
-            }}
-          />
-          <DashboardHeader
-            prefix={<Icon src={template.icon} />}
-            title={template.formTitle}
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <DatabaseForm
-            steps={[
-              <>
-                <Text size={16}>Specify resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">Specify your datastore CPU and RAM.</Text>
-                {errors.config?.instanceClass?.message && (
-                  <AppearingErrorContainer>
-                    <Spacer y={0.5} />
-                    <Error message={errors.config.instanceClass.message} />
-                  </AppearingErrorContainer>
-                )}
-                <Spacer y={0.5} />
-                <Text>Select an instance tier:</Text>
-                <Spacer height="20px" />
-                <Resources
-                  options={template.instanceTiers}
-                  selected={watchTier}
-                  onSelect={(option: ResourceOption) => {
-                    setValue("config.instanceClass", option.tier);
-                    setValue(
-                      "config.allocatedStorageGigabytes",
-                      option.storageGigabytes
-                    );
-                  }}
-                  highlight={"ram"}
-                />
-              </>,
-              <>
-                <Text size={16}>Credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. Your
-                  app will use them to connect to this datastore.
-                </Text>
-                <Spacer height="20px" />
-                <ConnectionInfo
-                  connectionInfo={{
-                    host: "(determined after creation)",
-                    port: 6379,
-                    password: watchDbPassword,
-                    username: "",
-                    database_name: "",
-                  }}
-                  type={template.type}
-                />
-              </>,
-            ]}
-            currentStep={100}
-            form={dbForm}
-          />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(DatabaseFormElasticacheRedis);

+ 0 - 154
dashboard/src/main/home/database-dashboard/forms/DatabaseFormRDSPostgres.tsx

@@ -1,154 +0,0 @@
-import React from "react";
-import { zodResolver } from "@hookform/resolvers/zod";
-import _ from "lodash";
-import { useForm } from "react-hook-form";
-import { withRouter, type RouteComponentProps } from "react-router";
-import { v4 as uuidv4 } from "uuid";
-
-import Back from "components/porter/Back";
-import Error from "components/porter/Error";
-import Selector from "components/porter/Selector";
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import {
-  dbFormValidator,
-  type DatastoreTemplate,
-  type DbFormData,
-  type ResourceOption,
-} from "lib/databases/types";
-
-import DashboardHeader from "../../cluster-dashboard/DashboardHeader";
-import ConnectionInfo from "../shared/ConnectionInfo";
-import Resources from "../shared/Resources";
-import DatabaseForm, {
-  AppearingErrorContainer,
-  CenterWrapper,
-  DarkMatter,
-  Div,
-  Icon,
-  StyledConfigureTemplate,
-} from "./DatabaseForm";
-
-type Props = RouteComponentProps & {
-  template: DatastoreTemplate;
-};
-
-const DatabaseFormRDSPostgres: React.FC<Props> = ({ history, template }) => {
-  const dbForm = useForm<DbFormData>({
-    resolver: zodResolver(dbFormValidator),
-    reValidateMode: "onSubmit",
-    defaultValues: {
-      config: {
-        type: "rds-postgres",
-        databaseName: "postgres",
-        masterUsername: "postgres",
-        masterUserPassword: uuidv4(),
-        engineVersion: "15.4",
-      },
-    },
-  });
-
-  const {
-    setValue,
-    formState: { errors },
-    watch,
-  } = dbForm;
-
-  const watchTier = watch("config.instanceClass", "unspecified");
-  const watchDbName = watch("config.databaseName");
-  const watchDbUsername = watch("config.masterUsername");
-  const watchDbPassword = watch("config.masterUserPassword");
-  const watchEngine = watch("config.engineVersion", "15.4");
-
-  return (
-    <CenterWrapper>
-      <Div>
-        <StyledConfigureTemplate>
-          <Back
-            onClick={() => {
-              history.push(`/datastores/new`);
-            }}
-          />
-          <DashboardHeader
-            prefix={<Icon src={template.icon} />}
-            title={template.formTitle}
-            capitalize={false}
-            disableLineBreak
-          />
-          <DarkMatter />
-          <DatabaseForm
-            steps={[
-              <>
-                <Text size={16}>Specify engine version</Text>
-                <Spacer y={0.5} />
-                <Selector<string>
-                  activeValue={watchEngine}
-                  setActiveValue={(value) => {
-                    setValue("config.engineVersion", value);
-                  }}
-                  width="300px"
-                  options={template.supportedEngineVersions.map((v) => ({
-                    value: v.name,
-                    label: v.displayName,
-                    key: v.name,
-                  }))}
-                />
-              </>,
-              <>
-                <Text size={16}>Specify resources</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  Specify your database CPU, RAM, and storage.
-                </Text>
-                {errors.config?.instanceClass?.message && (
-                  <AppearingErrorContainer>
-                    <Spacer y={0.5} />
-                    <Error message={errors.config.instanceClass.message} />
-                  </AppearingErrorContainer>
-                )}
-                <Spacer y={0.5} />
-                <Text>Select an instance tier:</Text>
-                <Spacer height="20px" />
-                <Resources
-                  options={template.instanceTiers}
-                  selected={watchTier}
-                  onSelect={(option: ResourceOption) => {
-                    setValue("config.instanceClass", option.tier);
-                    setValue(
-                      "config.allocatedStorageGigabytes",
-                      option.storageGigabytes
-                    );
-                  }}
-                  highlight={"storage"}
-                />
-              </>,
-              <>
-                <Text size={16}>Credentials</Text>
-                <Spacer y={0.5} />
-                <Text color="helper">
-                  These credentials never leave your own cloud environment. Your
-                  app will use them to connect to this datastore.
-                </Text>
-                <Spacer height="20px" />
-                <ConnectionInfo
-                  connectionInfo={{
-                    host: "(determined after creation)",
-                    port: 5432,
-                    password: watchDbPassword,
-                    username: watchDbUsername,
-                    database_name: watchDbName,
-                  }}
-                  type={template.type}
-                />
-              </>,
-            ]}
-            currentStep={100}
-            form={dbForm}
-          />
-        </StyledConfigureTemplate>
-      </Div>
-    </CenterWrapper>
-  );
-};
-
-export default withRouter(DatabaseFormRDSPostgres);

+ 425 - 0
dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx

@@ -0,0 +1,425 @@
+import React, { useEffect, useMemo, useState } from "react";
+import { Controller, useFormContext } from "react-hook-form";
+import styled, { keyframes } from "styled-components";
+import { match } from "ts-pattern";
+import { v4 as uuidv4 } from "uuid";
+
+import Back from "components/porter/Back";
+import Button from "components/porter/Button";
+import { ControlledInput } from "components/porter/ControlledInput";
+import { Error as ErrorComponent } from "components/porter/Error";
+import Selector from "components/porter/Selector";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import VerticalSteps from "components/porter/VerticalSteps";
+import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
+import { isAWSCluster } from "lib/clusters/types";
+import {
+  type DatastoreTemplate,
+  type DbFormData,
+  type ResourceOption,
+} from "lib/databases/types";
+import { useClusterList } from "lib/hooks/useCluster";
+
+import database from "assets/database.svg";
+
+import BlockSelect, {
+  type BlockSelectOption,
+} from "../../../../components/porter/BlockSelect";
+import {
+  DATASTORE_ENGINE_POSTGRES,
+  DATASTORE_ENGINE_REDIS,
+  DATASTORE_TEMPLATE_AWS_AURORA,
+  DATASTORE_TEMPLATE_AWS_ELASTICACHE,
+  DATASTORE_TEMPLATE_AWS_RDS,
+  DATASTORE_TEMPLATE_MANAGED_POSTGRES,
+  DATASTORE_TEMPLATE_MANAGED_REDIS,
+  SUPPORTED_DATASTORE_TEMPLATES,
+} from "../constants";
+import { useDatastoreFormContext } from "../DatastoreFormContextProvider";
+import ConnectionInfo from "../shared/ConnectionInfo";
+import Resources from "../shared/Resources";
+
+const DatastoreForm: React.FC = () => {
+  const [currentStep, setCurrentStep] = useState(1);
+  const [template, setTemplate] = useState<DatastoreTemplate | undefined>(
+    undefined
+  );
+
+  const { clusters } = useClusterList();
+  // only aws clusters supported right now
+  const awsClusters = useMemo(() => {
+    return clusters.filter(isAWSCluster);
+  }, [JSON.stringify(clusters)]);
+
+  const {
+    setValue,
+    formState: { errors },
+    register,
+    watch,
+  } = useFormContext<DbFormData>();
+  const watchTier = watch("config.instanceClass", "unspecified");
+  const watchDbName = watch("config.databaseName");
+  const watchDbUsername = watch("config.masterUsername");
+  const watchDbPassword = watch("config.masterUserPassword");
+  const watchClusterId = watch("clusterId", 0);
+  const watchWorkloadType = watch("workloadType", "unspecified");
+  const watchEngine = watch("engine", "UNKNOWN");
+  const watchInstanceClass = watch("config.instanceClass", "unspecified");
+
+  const { updateDatastoreButtonProps } = useDatastoreFormContext();
+
+  const availableEngines: BlockSelectOption[] = useMemo(() => {
+    return [DATASTORE_ENGINE_POSTGRES, DATASTORE_ENGINE_REDIS];
+  }, [awsClusters, watchClusterId]);
+  const availableWorkloadTypes: BlockSelectOption[] = useMemo(() => {
+    return [
+      {
+        name: "Production",
+        displayName: "Production",
+        icon: database,
+        description:
+          "Managed by a cloud provider. High availability, high performance, and durability.",
+        disabledOpts: !awsClusters.find((c) => c.id === watchClusterId)
+          ? {
+              tooltipText: "Currently only available for AWS clusters",
+            }
+          : undefined,
+      },
+      {
+        name: "Test",
+        displayName: "Development",
+        icon: database,
+        description:
+          "Hosted on a cluster. Availability is not guaranteed. Only use this for small, ephemeral workloads.",
+        descriptionColor: "warner",
+      },
+    ];
+  }, [awsClusters, watchClusterId]);
+  const availableHostTypes: BlockSelectOption[] = useMemo(() => {
+    const options = (
+      watchWorkloadType === "Production"
+        ? [
+            DATASTORE_TEMPLATE_AWS_RDS,
+            DATASTORE_TEMPLATE_AWS_AURORA,
+            DATASTORE_TEMPLATE_AWS_ELASTICACHE,
+          ]
+        : [
+            DATASTORE_TEMPLATE_MANAGED_POSTGRES,
+            DATASTORE_TEMPLATE_MANAGED_REDIS,
+          ]
+    ).filter((t) => t.highLevelType.name === watchEngine);
+    return options;
+  }, [watchWorkloadType, watchEngine]);
+
+  useEffect(() => {
+    if (clusters.length > 0) {
+      setValue("clusterId", clusters[0].id);
+    }
+  }, [JSON.stringify(clusters)]);
+
+  return (
+    <Div>
+      <StyledConfigureTemplate>
+        <Back to="/datastores" />
+        <DashboardHeader
+          prefix={<Icon src={database} />}
+          title={"Create a new datastore"}
+          capitalize={false}
+          disableLineBreak
+        />
+        <DarkMatter />
+        <VerticalSteps
+          steps={[
+            <>
+              <Text size={16}>Datastore name</Text>
+              <Spacer y={0.5} />
+              <Text color="helper">
+                Lowercase letters, numbers, and &quot;-&quot; only.
+              </Text>
+              <Spacer y={0.5} />
+              <ControlledInput
+                placeholder="ex: academic-sophon-db"
+                type="text"
+                width="300px"
+                error={errors.name?.message}
+                {...register("name")}
+              />
+              {clusters.length > 0 && (
+                <>
+                  <Spacer y={1} />
+                  <Selector<string>
+                    activeValue={watchClusterId.toString()}
+                    width="300px"
+                    options={clusters.map((c) => ({
+                      value: c.id.toString(),
+                      label: c.vanity_name,
+                      key: c.id.toString(),
+                    }))}
+                    setActiveValue={(value: string) => {
+                      setValue("clusterId", parseInt(value));
+                      setValue("engine", "UNKNOWN");
+                      setCurrentStep(1);
+                    }}
+                    label={"Cluster"}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Datastore engine</Text>
+              <Spacer y={0.5} />
+              <Controller
+                name="engine"
+                render={({ field: { value, onChange } }) => (
+                  <BlockSelect
+                    options={availableEngines}
+                    selectedOption={availableEngines.find(
+                      (e) => e.name === value
+                    )}
+                    setOption={(opt) => {
+                      onChange(opt.name);
+                      setValue("workloadType", "unspecified");
+                      setTemplate(undefined);
+                      setCurrentStep(2);
+                    }}
+                  />
+                )}
+              />
+            </>,
+            <>
+              <Text size={16}>Workload type</Text>
+              {watchEngine !== "UNKNOWN" && (
+                <>
+                  <Spacer y={0.5} />
+                  <Controller
+                    name="workloadType"
+                    render={({ field: { value, onChange } }) => (
+                      <BlockSelect
+                        options={availableWorkloadTypes}
+                        selectedOption={availableWorkloadTypes.find(
+                          (e) => e.name === value
+                        )}
+                        setOption={(opt) => {
+                          onChange(opt.name);
+                          setTemplate(undefined);
+                          setCurrentStep(3);
+                        }}
+                      />
+                    )}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Hosting option</Text>
+              {watchWorkloadType !== "unspecified" && (
+                <>
+                  <Spacer y={0.5} />
+                  <BlockSelect
+                    options={availableHostTypes}
+                    selectedOption={availableHostTypes.find(
+                      (a) => a.name === template?.name
+                    )}
+                    setOption={(opt) => {
+                      const templateMatch = SUPPORTED_DATASTORE_TEMPLATES.find(
+                        (t) => t.name === opt.name
+                      );
+                      if (!templateMatch) {
+                        return;
+                      }
+                      setTemplate(templateMatch);
+                      match(templateMatch)
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_AWS_ELASTICACHE.name,
+                          },
+                          () => {
+                            setValue("config.type", "elasticache-redis");
+                          }
+                        )
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_MANAGED_REDIS.name,
+                          },
+                          () => {
+                            setValue("config.type", "managed-redis");
+                          }
+                        )
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_MANAGED_POSTGRES.name,
+                          },
+                          () => {
+                            setValue("config.type", "managed-postgres");
+                            setValue("config.databaseName", "postgres");
+                            setValue("config.masterUsername", "postgres");
+                          }
+                        )
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_AWS_RDS.name,
+                          },
+                          () => {
+                            setValue("config.type", "rds-postgres");
+                            setValue("config.databaseName", "postgres");
+                            setValue("config.masterUsername", "postgres");
+                            setValue("config.engineVersion", "15.4");
+                          }
+                        )
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_AWS_AURORA.name,
+                          },
+                          () => {
+                            setValue("config.type", "rds-postgresql-aurora");
+                            setValue("config.databaseName", "postgres");
+                            setValue("config.masterUsername", "postgres");
+                            setValue("config.engineVersion", "15.4");
+                          }
+                        );
+                      setValue("config.instanceClass", "unspecified");
+                      setValue("config.masterUserPassword", uuidv4());
+                      setCurrentStep(4);
+                    }}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Specify resources</Text>
+              {template && (
+                <>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    Specify your datastore CPU and RAM.
+                  </Text>
+                  {errors.config?.instanceClass?.message && (
+                    <AppearingErrorContainer>
+                      <Spacer y={0.5} />
+                      <ErrorComponent
+                        message={errors.config.instanceClass.message}
+                      />
+                    </AppearingErrorContainer>
+                  )}
+                  <Spacer y={0.5} />
+                  <Text>Select an instance tier:</Text>
+                  <Spacer height="20px" />
+                  <Resources
+                    options={template.instanceTiers}
+                    selected={watchTier}
+                    onSelect={(option: ResourceOption) => {
+                      setValue("config.instanceClass", option.tier);
+                      setValue(
+                        "config.allocatedStorageGigabytes",
+                        option.storageGigabytes
+                      );
+                      setCurrentStep(6);
+                    }}
+                    highlight={watchEngine === "REDIS" ? "ram" : "storage"}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Credentials</Text>
+              {watchInstanceClass !== "unspecified" && template && (
+                <>
+                  <Spacer y={0.5} />
+                  <Text color="helper">
+                    These credentials never leave your own cloud environment.
+                    Your app will use them to connect to this datastore.
+                  </Text>
+                  <Spacer height="20px" />
+                  <ConnectionInfo
+                    connectionInfo={
+                      watchEngine === "REDIS"
+                        ? {
+                            host: "(determined after creation)",
+                            port: 6379,
+                            password: watchDbPassword,
+                            username: "",
+                            database_name: "",
+                          }
+                        : {
+                            host: "(determined after creation)",
+                            port: 5432,
+                            password: watchDbPassword,
+                            username: watchDbUsername,
+                            database_name: watchDbName,
+                          }
+                    }
+                    engine={template.engine}
+                  />
+                </>
+              )}
+            </>,
+            <>
+              <Text size={16}>Create datastore instance</Text>
+              <Spacer y={0.5} />
+              <Button
+                type="submit"
+                status={updateDatastoreButtonProps.status}
+                loadingText={updateDatastoreButtonProps.loadingText}
+                disabled={updateDatastoreButtonProps.isDisabled}
+              >
+                Create
+              </Button>
+            </>,
+          ]}
+          currentStep={currentStep}
+        />
+      </StyledConfigureTemplate>
+    </Div>
+  );
+};
+
+export default DatastoreForm;
+
+const Div = styled.div`
+  width: 100%;
+  max-width: 900px;
+`;
+
+const StyledConfigureTemplate = styled.div`
+  height: 100%;
+`;
+
+const DarkMatter = styled.div`
+  width: 100%;
+  margin-top: -5px;
+`;
+
+const Icon = styled.img`
+  margin-right: 15px;
+  height: 30px;
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const floatIn = keyframes`
+  0% {
+    opacity: 0;
+    transform: translateY(10px);
+  }
+  100% {
+    opacity: 1;
+    transform: translateY(0px);
+  }
+`;
+
+const AppearingErrorContainer = styled.div`
+  animation: ${floatIn} 0.5s;
+  animation-fill-mode: forwards;
+`;

+ 6 - 3
dashboard/src/main/home/database-dashboard/icons.tsx

@@ -1,7 +1,4 @@
 import {
-  DATASTORE_ENGINE_AURORA_POSTGRES,
-  DATASTORE_ENGINE_POSTGRES,
-  DATASTORE_ENGINE_REDIS,
   DATASTORE_TYPE_ELASTICACHE,
   DATASTORE_TYPE_RDS,
 } from "lib/databases/types";
@@ -13,6 +10,12 @@ import database from "assets/database.svg";
 import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
 
+import {
+  DATASTORE_ENGINE_AURORA_POSTGRES,
+  DATASTORE_ENGINE_POSTGRES,
+  DATASTORE_ENGINE_REDIS,
+} from "./constants";
+
 const datastoreIcons: Record<string, string> = {
   [DATASTORE_TYPE_ELASTICACHE.name]: awsElasticache,
   [DATASTORE_TYPE_RDS.name]: awsRDS,

+ 2 - 2
dashboard/src/main/home/database-dashboard/shared/ConnectAppsModal.tsx

@@ -17,7 +17,7 @@ import {
   type AppRevisionWithSource,
 } from "main/home/app-dashboard/apps/types";
 import EnvGroupRow from "main/home/app-dashboard/validate-apply/app-settings/EnvGroupRow";
-import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
+import { useDatastore } from "lib/hooks/useDatastore";
 import { useEnvGroupList } from "lib/hooks/useEnvGroups";
 import { useIntercom } from "lib/hooks/useIntercom";
 
@@ -32,7 +32,7 @@ type Props = {
 
 const ConnectAppsModal: React.FC<Props> = ({ closeModal }) => {
   const { datastore, projectId } = useDatastoreContext();
-  const { attachDatastoreToAppInstances } = useDatastoreMethods();
+  const { attachDatastoreToAppInstances } = useDatastore();
   const { envGroups, isLoading } = useEnvGroupList({
     clusterId: datastore.connected_cluster_ids.length
       ? datastore.connected_cluster_ids[0]

+ 29 - 6
dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx

@@ -1,4 +1,5 @@
 import React from "react";
+import styled from "styled-components";
 
 import ClickToCopy from "components/porter/ClickToCopy";
 import Container from "components/porter/Container";
@@ -6,18 +7,17 @@ import Fieldset from "components/porter/Fieldset";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import {
-  DATASTORE_TYPE_ELASTICACHE,
   type DatastoreConnectionInfo,
-  type DatastoreType,
+  type DatastoreEngine,
 } from "lib/databases/types";
 
-import { Blur, RevealButton } from "../forms/DatabaseForm";
+import { DATASTORE_ENGINE_REDIS } from "../constants";
 
 type Props = {
   connectionInfo: DatastoreConnectionInfo;
-  type: DatastoreType;
+  engine: DatastoreEngine;
 };
-const ConnectionInfo: React.FC<Props> = ({ connectionInfo, type }) => {
+const ConnectionInfo: React.FC<Props> = ({ connectionInfo, engine }) => {
   const [isPasswordHidden, setIsPasswordHidden] = React.useState<boolean>(true);
 
   return (
@@ -42,7 +42,7 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, type }) => {
               </ClickToCopy>
             </td>
           </tr>
-          {type === DATASTORE_TYPE_ELASTICACHE ? (
+          {engine === DATASTORE_ENGINE_REDIS ? (
             <tr>
               <td>
                 <Text>Auth token</Text>
@@ -142,3 +142,26 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, type }) => {
 };
 
 export default ConnectionInfo;
+
+const RevealButton = styled.div`
+  background: ${(props) => props.theme.fg};
+  padding: 5px 10px;
+  border-radius: 5px;
+  border: 1px solid #494b4f;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :hover {
+    filter: brightness(120%);
+  }
+`;
+
+const Blur = styled.div`
+  filter: blur(5px);
+  -webkit-filter: blur(5px);
+  position: relative;
+  margin-left: -5px;
+  font-family: monospace;
+`;

+ 9 - 0
dashboard/src/main/home/database-dashboard/shared/Resources.tsx

@@ -80,6 +80,15 @@ const StyledResourceOption = styled.div<{ selected?: boolean }>`
   :hover {
     border: 1px solid #ffffff;
   }
+  animation: fadeIn 0.3s 0s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
 `;
 
 const StorageTag = styled.div`

+ 1 - 1
dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx

@@ -59,7 +59,7 @@ const ConnectTab: React.FC = () => {
         <Spacer y={0.5} />
         <ConnectionInfo
           connectionInfo={datastore.credential}
-          type={datastore.template.type}
+          engine={datastore.template.engine}
         />
         <Spacer y={0.5} />
         <Text color="warner">

+ 2 - 2
dashboard/src/main/home/database-dashboard/tabs/ConnectedAppsTab.tsx

@@ -7,7 +7,7 @@ import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import SelectableAppList from "main/home/app-dashboard/apps/SelectableAppList";
-import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
+import { useDatastore } from "lib/hooks/useDatastore";
 import { useLatestAppRevisions } from "lib/hooks/useLatestAppRevisions";
 
 import { Context } from "shared/Context";
@@ -26,7 +26,7 @@ const ConnectedAppsTab: React.FC = () => {
     projectId,
     clusterId,
   });
-  const { attachDatastoreToAppInstances } = useDatastoreMethods();
+  const { attachDatastoreToAppInstances } = useDatastore();
   const history = useHistory();
 
   const { connectedApps, remainingApps } = useMemo(() => {

+ 2 - 2
dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx

@@ -11,7 +11,7 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { type UpdateClusterButtonProps } from "main/home/infrastructure-dashboard/ClusterFormContextProvider";
 import { getErrorMessageFromNetworkCall } from "lib/hooks/useCluster";
-import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
+import { useDatastore } from "lib/hooks/useDatastore";
 
 import trash from "assets/trash.png";
 
@@ -22,7 +22,7 @@ const SettingsTab: React.FC = () => {
     useState(false);
 
   const { datastore } = useDatastoreContext();
-  const { deleteDatastore } = useDatastoreMethods();
+  const { deleteDatastore } = useDatastore();
 
   return (
     <div>

+ 2 - 1
dashboard/src/shared/api.tsx

@@ -2819,8 +2819,9 @@ const getDatastoreCredential = baseApi<
 const updateDatastore = baseApi<
   {
     name: string;
-    type: "RDS" | "ELASTICACHE";
+    type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS";
     engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
+
     values: any;
   },
   { project_id: number; cluster_id: number }

+ 6 - 1
internal/models/datastore.go

@@ -38,7 +38,7 @@ type Datastore struct {
 	// For AWS EKS clusters, this will be an ARN for the final target role in the assume role chain.
 	CloudProviderCredentialIdentifier string `json:"cloud_provider_credential_identifier"`
 
-	// Type is the type of datastore. Accepted values: [RDS, ELASTICACHE]
+	// Type is the type of datastore. Accepted values: [RDS, ELASTICACHE, MANAGED_POSTGRES, MANAGED_REDIS]
 	Type string `json:"type"`
 
 	// Engine is the engine of the datastore. Accepted values: [POSTGRES, AURORA-POSTGRES, REDIS]
@@ -50,3 +50,8 @@ type Datastore struct {
 	// OnManagementCluster is a flag that indicates whether the datastore is hosted on the management cluster or on the customer's cluster
 	OnManagementCluster bool `json:"on_management_cluster" gorm:"not null;default:false"`
 }
+
+// IsLegacy returns true if the datastore is a legacy datastore
+func (d *Datastore) IsLegacy() bool {
+	return !d.OnManagementCluster && !(d.Type == "MANAGED_POSTGRES" || d.Type == "MANAGED_REDIS")
+}