Преглед на файлове

[POR-2245] add deletion bar for datastores (#4214)

Feroze Mohideen преди 2 години
родител
ревизия
29b6d12f25

+ 24 - 15
api/server/handlers/datastore/delete.go

@@ -4,6 +4,9 @@ import (
 	"context"
 	"net/http"
 
+	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/authz"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/handlers/release"
@@ -12,7 +15,6 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/porter-dev/porter/internal/datastore"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 )
@@ -48,27 +50,34 @@ func (h *DeleteDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	}
 	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-name", Value: datastoreName})
 
-	datastore, err := datastore.DeleteRecord(ctx, datastore.DeleteRecordInput{
-		ProjectID:           project.ID,
-		Name:                datastoreName,
-		DatastoreRepository: h.Repo().Datastore(),
-	})
+	datastoreRecord, err := h.Repo().Datastore().GetByProjectIDAndName(ctx, project.ID, datastoreName)
+	if err != nil {
+		err = telemetry.Error(ctx, span, err, "datastore record not found")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if datastoreRecord == nil || datastoreRecord.ID == uuid.Nil {
+		err = telemetry.Error(ctx, span, nil, "datastore record does not exist")
+		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
+		return
+	}
+
+	_, err = h.Repo().Datastore().UpdateStatus(ctx, datastoreRecord, models.DatastoreStatus_AwaitingDeletion)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error deleting datastore record")
+		err = telemetry.Error(ctx, span, err, "error updating datastore status")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}
 
-	// TODO: replace this with a CCP call
-	err = h.UninstallDatastore(ctx, UninstallDatastoreInput{
-		ProjectID:                         project.ID,
-		Name:                              datastoreName,
-		CloudProvider:                     datastore.CloudProvider,
-		CloudProviderCredentialIdentifier: datastore.CloudProviderCredentialIdentifier,
-		Request:                           r,
+	updateReq := connect.NewRequest(&porterv1.UpdateDatastoreRequest{
+		ProjectId:   int64(project.ID),
+		DatastoreId: datastoreRecord.ID.String(),
 	})
+
+	_, err = h.Config().ClusterControlPlaneClient.UpdateDatastore(ctx, updateReq)
 	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error uninstalling datastore")
+		err := telemetry.Error(ctx, span, err, "error calling ccp update datastore")
 		h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
 		return
 	}

+ 12 - 16
api/server/handlers/datastore/get.go

@@ -76,6 +76,14 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	datastore := Datastore{
+		Name:         datastoreRecord.Name,
+		Type:         datastoreRecord.Type,
+		Engine:       datastoreRecord.Engine,
+		CreatedAtUTC: datastoreRecord.CreatedAt,
+		Status:       string(datastoreRecord.Status),
+	}
+
 	if datastoreRecord.CloudProvider != SupportedDatastoreCloudProvider_AWS {
 		err = telemetry.Error(ctx, span, nil, "unsupported datastore cloud provider")
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
@@ -101,23 +109,11 @@ func (c *GetDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		CCPClient:           c.Config().ClusterControlPlaneClient,
 		DatastoreRepository: c.Repo().Datastore(),
 	})
-	if err != nil {
-		err = telemetry.Error(ctx, span, err, "error getting datastore")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
-	}
-	if len(datastores) == 0 {
-		err = telemetry.Error(ctx, span, nil, "datastore not found")
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusNotFound))
-		return
-	}
-	if len(datastores) > 1 {
-		err = telemetry.Error(ctx, span, nil, "unexpected number of datastores found matching name")
-		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "datastore-count", Value: len(datastores)})
-		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
-		return
+	if err == nil && len(datastores) == 1 {
+		ds := datastores[0]
+		datastore.Env = ds.Env
+		datastore.Metadata = ds.Metadata
 	}
-	datastore := datastores[0]
 
 	resp.Datastore = datastore
 

+ 38 - 3
dashboard/src/lib/databases/types.ts

@@ -35,6 +35,11 @@ export const datastoreValidator = z.object({
     "CONFIGURING_ENHANCED_MONITORING",
     "BACKING_UP",
     "AVAILABLE",
+    "AWAITING_DELETION",
+    "DELETING_REPLICATION_GROUP",
+    "DELETING_PARAMETER_GROUP",
+    "DELETING_RECORD",
+    "DELETED",
   ]),
 });
 
@@ -69,9 +74,18 @@ export const DATASTORE_ENGINE_MEMCACHED = {
   displayName: "Memcached",
 };
 
-export type DatastoreType = z.infer<typeof datastoreValidator>["type"];
-export const DATASTORE_TYPE_RDS = "RDS" as const;
-export const DATASTORE_TYPE_ELASTICACHE = "ELASTICACHE" as const;
+export type DatastoreType = {
+  name: z.infer<typeof datastoreValidator>["type"];
+  displayName: string;
+};
+export const DATASTORE_TYPE_RDS: DatastoreType = {
+  name: "RDS" as const,
+  displayName: "RDS",
+};
+export const DATASTORE_TYPE_ELASTICACHE: DatastoreType = {
+  name: "ELASTICACHE" as const,
+  displayName: "ElastiCache",
+};
 
 export type DatastoreState = {
   state: z.infer<typeof datastoreValidator>["status"];
@@ -101,6 +115,26 @@ export const DATASTORE_STATE_AVAILABLE: DatastoreState = {
   state: "AVAILABLE",
   displayName: "Finishing provision",
 };
+export const DATASTORE_STATE_AWAITING_DELETION: DatastoreState = {
+  state: "AWAITING_DELETION",
+  displayName: "Awaiting deletion",
+};
+export const DATASTORE_STATE_DELETING_REPLICATION_GROUP: DatastoreState = {
+  state: "DELETING_REPLICATION_GROUP",
+  displayName: "Deleting replication group",
+};
+export const DATASTORE_STATE_DELETING_PARAMETER_GROUP: DatastoreState = {
+  state: "DELETING_PARAMETER_GROUP",
+  displayName: "Deleting parameter group",
+};
+export const DATASTORE_STATE_DELETING_RECORD: DatastoreState = {
+  state: "DELETING_RECORD",
+  displayName: "Deleting all resources",
+};
+export const DATASTORE_STATE_DELETED: DatastoreState = {
+  state: "DELETED",
+  displayName: "Wrapping up",
+};
 
 export type DatastoreTemplate = {
   type: DatastoreType;
@@ -112,6 +146,7 @@ export type DatastoreTemplate = {
   instanceTiers: ResourceOption[];
   formTitle: string;
   creationStateProgression: DatastoreState[];
+  deletionStateProgression: DatastoreState[];
 };
 
 const instanceTierValidator = z.enum([

+ 5 - 6
dashboard/src/lib/hooks/useDatabaseList.ts

@@ -4,8 +4,7 @@ import { useQuery } from "@tanstack/react-query";
 import { SUPPORTED_DATASTORE_TEMPLATES } from "main/home/database-dashboard/constants";
 import {
   datastoreListResponseValidator,
-  type DatastoreTemplate,
-  type SerializedDatastore,
+  type ClientDatastore,
 } from "lib/databases/types";
 
 import api from "shared/api";
@@ -13,13 +12,13 @@ import { Context } from "shared/Context";
 import { valueExists } from "shared/util";
 
 type DatastoreListType = {
-  datastores: Array<SerializedDatastore & { template: DatastoreTemplate }>;
+  datastores: ClientDatastore[];
   isLoading: boolean;
 };
 export const useDatastoreList = (): DatastoreListType => {
   const { currentProject } = useContext(Context);
 
-  const { data: datastores, isLoading: isLoadingDatastores } = useQuery(
+  const { data: datastores = [], isLoading: isLoadingDatastores } = useQuery(
     ["listDatastores"],
     async () => {
       if (!currentProject?.id || currentProject.id === -1) {
@@ -40,7 +39,7 @@ export const useDatastoreList = (): DatastoreListType => {
       return parsed.datastores
         .map((d) => {
           const template = SUPPORTED_DATASTORE_TEMPLATES.find(
-            (t) => t.type === d.type && t.engine.name === d.engine
+            (t) => t.type.name === d.type && t.engine.name === d.engine
           );
 
           // filter out this datastore if it is a type we do not recognize
@@ -55,7 +54,7 @@ export const useDatastoreList = (): DatastoreListType => {
   );
 
   return {
-    datastores: datastores ?? [],
+    datastores,
     isLoading: isLoadingDatastores,
   };
 };

+ 1 - 1
dashboard/src/lib/hooks/useDatabaseMethods.ts

@@ -130,7 +130,7 @@ export const useDatastoreMethods = (): DatastoreHook => {
         }
       );
 
-      await queryClient.invalidateQueries({ queryKey: ["listDatastores"] });
+      await queryClient.invalidateQueries({ queryKey: ["getDatastore"] });
     },
     [currentProject]
   );

+ 66 - 49
dashboard/src/main/home/app-dashboard/create-app/RepoSettings.tsx

@@ -1,25 +1,28 @@
-import React, { useCallback, useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import { useQuery } from "@tanstack/react-query";
-import api from "shared/api";
+import AnimateHeight from "react-animate-height";
 import { Controller, useFormContext } from "react-hook-form";
-import Text from "components/porter/Text";
-import Spacer from "components/porter/Spacer";
 import styled from "styled-components";
-import Input from "components/porter/Input";
+import { match } from "ts-pattern";
+import { z } from "zod";
+
+import Loading from "components/Loading";
 import { ControlledInput } from "components/porter/ControlledInput";
+import Input from "components/porter/Input";
 import Select from "components/porter/Select";
-import AnimateHeight, { Height } from "react-animate-height";
-import { z } from "zod";
-import { PorterAppFormData, SourceOptions } from "lib/porter-apps";
-import RepositorySelector from "../build-settings/RepositorySelector";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type PorterAppFormData, type SourceOptions } from "lib/porter-apps";
+import { type BuildOptions } from "lib/porter-apps/build";
+
+import api from "shared/api";
+
 import BranchSelector from "../build-settings/BranchSelector";
-import BuildpackSettings, { DEFAULT_BUILDERS } from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
-import { match } from "ts-pattern";
-import { BuildOptions } from "lib/porter-apps/build";
-import Loading from "components/Loading";
+import RepositorySelector from "../build-settings/RepositorySelector";
+import BuildpackSettings, {
+  DEFAULT_BUILDERS,
+} from "../validate-apply/build-settings/buildpacks/BuildpackSettings";
 import DockerfileSettings from "../validate-apply/build-settings/docker/DockerfileSettings";
-import useResizeObserver from "lib/hooks/useResizeObserver";
-import CollapsibleContainer from "components/porter/CollapsibleContainer";
 
 type Props = {
   projectId: number;
@@ -46,15 +49,23 @@ const RepoSettings: React.FC<Props> = ({
   const { control, register, setValue } = useFormContext<PorterAppFormData>();
   const [showSettings, setShowSettings] = useState<boolean>(false);
 
-  const repoIsSet = useMemo(() => source.git_repo_name !== "", [
-    source.git_repo_name,
-  ]);
-  const branchIsSet = useMemo(() => source.git_branch !== "", [
-    source.git_branch,
-  ]);
+  const repoIsSet = useMemo(
+    () => source.git_repo_name !== "",
+    [source.git_repo_name]
+  );
+  const branchIsSet = useMemo(
+    () => source.git_branch !== "",
+    [source.git_branch]
+  );
 
   const { data: branchContents, isLoading } = useQuery<BranchContents>(
-    ["getBranchContents", projectId, source.git_branch, source.git_repo_name, appExists],
+    [
+      "getBranchContents",
+      projectId,
+      source.git_branch,
+      source.git_repo_name,
+      appExists,
+    ],
     async () => {
       const res = await api.getBranchContents(
         "<token>",
@@ -81,11 +92,16 @@ const RepoSettings: React.FC<Props> = ({
       return;
     }
 
-    const item = branchContents.find((item) =>
-      item.path.includes("Dockerfile") && item.type === "file"
+    const item = branchContents.find(
+      (item) => item.path.includes("Dockerfile") && item.type === "file"
     );
     if (item) {
-      setValue("app.build.dockerfile", item.path.startsWith("./") || item.path.startsWith("/") ? item.path : `./${item.path}`);
+      setValue(
+        "app.build.dockerfile",
+        item.path.startsWith("./") || item.path.startsWith("/")
+          ? item.path
+          : `./${item.path}`
+      );
       setValue("app.build.method", "docker");
     } else {
       setValue("app.build.buildpacks", []);
@@ -97,12 +113,12 @@ const RepoSettings: React.FC<Props> = ({
     <div>
       <Text size={16}>Build settings</Text>
       <Spacer y={0.5} />
-      {!appExists && 
+      {!appExists && (
         <>
           <Text color="helper">Specify your GitHub repository.</Text>
           <Spacer y={0.5} />
         </>
-      } 
+      )}
       {!source.git_repo_name && (
         <Controller
           name="source.git_repo_name"
@@ -135,7 +151,7 @@ const RepoSettings: React.FC<Props> = ({
             label="GitHub repository:"
             width="100%"
             value={source.git_repo_name}
-            setValue={() => { }}
+            setValue={() => {}}
             placeholder=""
           />
           {!appExists && (
@@ -161,12 +177,12 @@ const RepoSettings: React.FC<Props> = ({
             </>
           )}
           <Spacer y={0.5} />
-          {!appExists && 
+          {!appExists && (
             <>
               <Text color="helper">Specify your GitHub branch.</Text>
               <Spacer y={0.5} />
             </>
-          }
+          )}
           {!source.git_branch && (
             <Controller
               name="source.git_branch"
@@ -174,7 +190,9 @@ const RepoSettings: React.FC<Props> = ({
               render={({ field: { onChange } }) => (
                 <ExpandedWrapper>
                   <BranchSelector
-                    setBranch={(branch: string) => onChange(branch)}
+                    setBranch={(branch: string) => {
+                      onChange(branch);
+                    }}
                     repo_name={source.git_repo_name}
                     git_repo_id={source.git_repo_id}
                   />
@@ -190,10 +208,10 @@ const RepoSettings: React.FC<Props> = ({
                 type="text"
                 width="100%"
                 value={source.git_branch}
-                setValue={() => { }}
+                setValue={() => {}}
                 placeholder=""
               />
-              {!appExists &&
+              {!appExists && (
                 <>
                   <BackButton
                     width="145px"
@@ -212,14 +230,16 @@ const RepoSettings: React.FC<Props> = ({
                   </BackButton>
                   <Spacer y={0.5} />
                 </>
-              }
+              )}
               <Spacer y={0.5} />
-              {!appExists &&
+              {!appExists && (
                 <>
-                  <Text color="helper">Specify your application root path.</Text>
+                  <Text color="helper">
+                    Specify your application root path.
+                  </Text>
                   <Spacer y={0.5} />
                 </>
-              }
+              )}
               <ControlledInput
                 placeholder="ex: ./"
                 width="100%"
@@ -228,18 +248,18 @@ const RepoSettings: React.FC<Props> = ({
                 label={"Application root path:"}
               />
               <Spacer y={1} />
-              {isLoading && !appExists ?
+              {isLoading && !appExists ? (
                 <AdvancedBuildTitle>
                   <Loading />
                 </AdvancedBuildTitle>
-                :
+              ) : (
                 <StyledAdvancedBuildSettings
                   showSettings={showSettings}
                   onClick={() => {
                     setShowSettings(!showSettings);
                   }}
                 >
-                  {build.method == "docker" ? (
+                  {build.method === "docker" ? (
                     <AdvancedBuildTitle>
                       <i className="material-icons dropdown">arrow_drop_down</i>
                       Configure Dockerfile settings
@@ -251,11 +271,8 @@ const RepoSettings: React.FC<Props> = ({
                     </AdvancedBuildTitle>
                   )}
                 </StyledAdvancedBuildSettings>
-              }
-              <AnimateHeight
-                duration={500}
-                height={showSettings ? "auto" : 0}
-              >
+              )}
+              <AnimateHeight duration={500} height={showSettings ? "auto" : 0}>
                 <StyledSourceBox>
                   <Controller
                     name="app.build.method"
@@ -269,13 +286,13 @@ const RepoSettings: React.FC<Props> = ({
                           { value: "pack", label: "Buildpacks" },
                         ]}
                         setValue={(option: string) => {
-                          if (option == "docker") {
+                          if (option === "docker") {
                             onChange("docker");
-                          } else if (option == "pack") {
+                          } else if (option === "pack") {
                             // if toggling from docker to pack, initialize buildpacks to empty array and builder to default
                             onChange("pack");
                             setValue("app.build.buildpacks", []);
-                            setValue("app.build.builder", DEFAULT_BUILDERS[0])
+                            setValue("app.build.builder", DEFAULT_BUILDERS[0]);
                           }
                         }}
                         label="Build method"
@@ -383,7 +400,7 @@ const StyledAdvancedBuildSettings = styled.div`
     cursor: pointer;
     border-radius: 20px;
     transform: ${(props: { showSettings: boolean }) =>
-    props.showSettings ? "" : "rotate(-90deg)"};
+      props.showSettings ? "" : "rotate(-90deg)"};
   }
 `;
 

+ 13 - 20
dashboard/src/main/home/database-dashboard/CreateDatabase.tsx

@@ -1,9 +1,8 @@
 import React, { useMemo } from "react";
 import _ from "lodash";
-import { withRouter, type RouteComponentProps } from "react-router";
+import { useHistory, useLocation, withRouter } from "react-router";
 import styled from "styled-components";
 import { match } from "ts-pattern";
-import { z } from "zod";
 
 import Back from "components/porter/Back";
 import Spacer from "components/porter/Spacer";
@@ -26,28 +25,19 @@ import DatabaseFormElasticacheRedis from "./forms/DatabaseFormElasticacheRedis";
 import DatabaseFormRDSPostgres from "./forms/DatabaseFormRDSPostgres";
 import EngineTag from "./tags/EngineTag";
 
-type Props = RouteComponentProps;
-const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
-  const templateMatch: DatastoreTemplate | undefined = useMemo(() => {
-    const { params } = queryMatch;
-    const validParams = z
-      .object({
-        type: z.string(),
-        engine: z.string(),
-      })
-      .safeParse(params);
-
-    if (!validParams.success) {
-      return undefined;
-    }
+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 === validParams.data.type &&
-        t.engine.name === validParams.data.engine
+        t.type.name === queryParams.get("type") &&
+        t.engine.name === queryParams.get("engine")
     );
-  }, [queryMatch]);
+  }, [queryParams]);
 
   return (
     <StyledTemplateComponent>
@@ -91,7 +81,10 @@ const CreateDatabase: React.FC<Props> = ({ history, match: queryMatch }) => {
                     disabled={disabled}
                     key={`${name}-${engine.name}`}
                     onClick={() => {
-                      history.push(`/datastores/new/${type}/${engine.name}`);
+                      const query = new URLSearchParams();
+                      query.set("type", type.name);
+                      query.set("engine", engine.name);
+                      history.push(`/datastores/new?${query.toString()}`);
                     }}
                   >
                     <TemplateHeader>

+ 2 - 1
dashboard/src/main/home/database-dashboard/DatabaseContextProvider.tsx

@@ -64,7 +64,8 @@ export const DatastoreContextProvider: React.FC<
 
       const datastore = results.datastore;
       const matchingTemplate = SUPPORTED_DATASTORE_TEMPLATES.find(
-        (t) => t.type === datastore.type && t.engine.name === datastore.engine
+        (t) =>
+          t.type.name === datastore.type && t.engine.name === datastore.engine
       );
 
       // this datastore is a type we do not recognize

+ 2 - 3
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -13,7 +13,6 @@ import { readableDate } from "shared/string_utils";
 import { useDatastoreContext } from "./DatabaseContextProvider";
 import { getDatastoreIcon } from "./icons";
 import EngineTag from "./tags/EngineTag";
-import { datastoreField } from "./utils";
 
 const DatabaseHeader: React.FC = () => {
   const { datastore } = useDatastoreContext();
@@ -31,8 +30,8 @@ const DatabaseHeader: React.FC = () => {
               <EngineTag engine={datastore.template.engine} heightPixels={15} />
             </Container>
           </Container>
-          {match(datastoreField(datastore, "status"))
-            .with("available", () => (
+          {match(datastore.status)
+            .with("AVAILABLE", () => (
               <Container row>
                 <StatusDot status={"available"} heightPixels={11} />
               </Container>

+ 34 - 32
dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx

@@ -1,50 +1,52 @@
 import React, { useMemo } from "react";
-import { match } from "ts-pattern";
 
 import StatusBar from "components/porter/StatusBar";
-import {
-  DATASTORE_TYPE_ELASTICACHE,
-  DATASTORE_TYPE_RDS,
-} from "lib/databases/types";
 
 import { useDatastoreContext } from "./DatabaseContextProvider";
 
 const DatastoreProvisioningIndicator: React.FC = () => {
   const { datastore } = useDatastoreContext();
 
-  const { percentCompleted, title, titleDescriptor } = useMemo(() => {
-    const creationSteps = datastore.template.creationStateProgression.map(
-      (s) => s.state
-    );
-    const stepsCompleted = creationSteps.indexOf(datastore.status) + 1;
-    const percentCompleted =
-      stepsCompleted === -1
-        ? 0
-        : (stepsCompleted / creationSteps.length) * 100.0;
-    const title = match(datastore.template)
-      .with({ type: DATASTORE_TYPE_RDS }, () => "RDS provisioning status")
-      .with(
-        { type: DATASTORE_TYPE_ELASTICACHE },
-        () => "Elasticache provisioning status"
-      )
-      .exhaustive();
-    const stateMatch = datastore.template.creationStateProgression.find(
-      (s) => s.state === datastore.status
-    );
-    const titleDescriptor = stateMatch
-      ? `${stateMatch.displayName}...`
-      : undefined;
-    return { percentCompleted, title, titleDescriptor };
-  }, [datastore]);
+  const { percentCompleted, title, titleDescriptor, isCreating } =
+    useMemo(() => {
+      const creationSteps = datastore.template.creationStateProgression.map(
+        (s) => s.state
+      );
+      const deletionSteps = datastore.template.deletionStateProgression.map(
+        (s) => s.state
+      );
+      const isCreating =
+        creationSteps.find((s) => s === datastore.status) != null;
+      const steps = isCreating ? creationSteps : deletionSteps;
+      const stateMatch = isCreating
+        ? datastore.template.creationStateProgression.find(
+            (s) => s.state === datastore.status
+          )
+        : datastore.template.deletionStateProgression.find(
+            (s) => s.state === datastore.status
+          );
+
+      const stepsCompleted = steps.indexOf(datastore.status) + 1;
+      const percentCompleted =
+        stepsCompleted === -1 ? 0 : (stepsCompleted / steps.length) * 100.0;
+      const title = `${datastore.template.type.displayName} ${
+        isCreating ? "provisioning" : "deletion"
+      } status`;
+
+      const titleDescriptor = stateMatch
+        ? `${stateMatch.displayName}...`
+        : undefined;
+      return { percentCompleted, title, titleDescriptor, isCreating };
+    }, [datastore]);
 
   return (
     <StatusBar
       icon={datastore.template.icon}
       title={title}
       titleDescriptor={titleDescriptor}
-      subtitle={
-        "Setup can take up to 20 minutes. You can close this window and come back later."
-      }
+      subtitle={`${
+        isCreating ? "Setup" : "Deletion"
+      } can take up to 20 minutes. You can close this window and come back later.`}
       percentCompleted={percentCompleted}
     />
   );

+ 23 - 0
dashboard/src/main/home/database-dashboard/constants.ts

@@ -4,10 +4,15 @@ import {
   DATASTORE_ENGINE_POSTGRES,
   DATASTORE_ENGINE_REDIS,
   DATASTORE_STATE_AVAILABLE,
+  DATASTORE_STATE_AWAITING_DELETION,
   DATASTORE_STATE_BACKING_UP,
   DATASTORE_STATE_CONFIGURING_ENHANCED_MONITORING,
   DATASTORE_STATE_CONFIGURING_LOG_EXPORTS,
   DATASTORE_STATE_CREATING,
+  DATASTORE_STATE_DELETED,
+  DATASTORE_STATE_DELETING_PARAMETER_GROUP,
+  DATASTORE_STATE_DELETING_RECORD,
+  DATASTORE_STATE_DELETING_REPLICATION_GROUP,
   DATASTORE_STATE_MODIFYING,
   DATASTORE_TYPE_ELASTICACHE,
   DATASTORE_TYPE_RDS,
@@ -58,6 +63,11 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
       DATASTORE_STATE_BACKING_UP,
       DATASTORE_STATE_AVAILABLE,
     ],
+    deletionStateProgression: [
+      DATASTORE_STATE_AWAITING_DELETION,
+      DATASTORE_STATE_DELETING_RECORD,
+      DATASTORE_STATE_DELETED,
+    ],
   }),
   Object.freeze({
     name: "Amazon Aurora",
@@ -88,6 +98,11 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
       DATASTORE_STATE_CREATING,
       DATASTORE_STATE_AVAILABLE,
     ],
+    deletionStateProgression: [
+      DATASTORE_STATE_AWAITING_DELETION,
+      DATASTORE_STATE_DELETING_RECORD,
+      DATASTORE_STATE_DELETED,
+    ],
   }),
   Object.freeze({
     name: "Amazon ElastiCache",
@@ -133,6 +148,13 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
       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",
@@ -145,5 +167,6 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
     instanceTiers: [],
     formTitle: "Create an ElastiCache Memcached instance",
     creationStateProgression: [],
+    deletionStateProgression: [],
   }),
 ];

+ 2 - 2
dashboard/src/main/home/database-dashboard/icons.tsx

@@ -14,8 +14,8 @@ import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
 
 const datastoreIcons: Record<string, string> = {
-  [DATASTORE_TYPE_ELASTICACHE]: awsElasticache,
-  [DATASTORE_TYPE_RDS]: awsRDS,
+  [DATASTORE_TYPE_ELASTICACHE.name]: awsElasticache,
+  [DATASTORE_TYPE_RDS.name]: awsRDS,
 };
 
 const engineIcons: Record<string, string> = {

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

@@ -90,7 +90,7 @@ const ConnectTab: React.FC = () => {
             The datastore client of your application should use these
             environment variables to create a connection.
           </Text>
-          {datastore.type === "ELASTICACHE" && (
+          {datastore.template.type.name === "ELASTICACHE" && (
             <>
               <Spacer y={0.5} />
               <Text color="warner">

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

@@ -1,19 +1,19 @@
 import React, { useContext } from "react";
-import { useHistory } from "react-router";
 import styled from "styled-components";
 
 import Button from "components/porter/Button";
+import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { useDatastoreMethods } from "lib/hooks/useDatabaseMethods";
 
 import { Context } from "shared/Context";
+import trash from "assets/trash.png";
 
 import { useDatastoreContext } from "../DatabaseContextProvider";
 
 const SettingsTab: React.FC = () => {
   const { setCurrentOverlay } = useContext(Context);
-  const history = useHistory();
   const { datastore } = useDatastoreContext();
   const { deleteDatastore } = useDatastoreMethods();
   const handleDeletionSubmit = async (): Promise<void> => {
@@ -24,7 +24,6 @@ const SettingsTab: React.FC = () => {
     try {
       await deleteDatastore(datastore.name);
       setCurrentOverlay(null);
-      history.push("/datastores");
     } catch (error) {
       // todo: handle error
     }
@@ -54,6 +53,8 @@ const SettingsTab: React.FC = () => {
         </Text>
         <Spacer y={0.5} />
         <Button color="#b91133" onClick={handleDeletionClick}>
+          <Icon src={trash} height={"15px"} />
+          <Spacer inline x={0.5} />
           Delete {datastore.name}
         </Button>
       </InnerWrapper>

+ 2 - 0
internal/models/datastore.go

@@ -13,6 +13,8 @@ const (
 	DatastoreStatus_Creating DatastoreStatus = "CREATING"
 	// DatastoreStatus_Available is the status for a datastore that is available
 	DatastoreStatus_Available DatastoreStatus = "AVAILABLE"
+	// DatastoreStatus_AwaitingDeletion is the status for a datastore that is awaiting deletion
+	DatastoreStatus_AwaitingDeletion DatastoreStatus = "AWAITING_DELETION"
 )
 
 // Datastore is a database model that represents a Porter-provisioned datastore

+ 2 - 0
internal/repository/datastore.go

@@ -16,4 +16,6 @@ type DatastoreRepository interface {
 	ListByProjectID(ctx context.Context, projectID uint) ([]*models.Datastore, error)
 	// Delete deletes a datastore by id
 	Delete(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error)
+	// UpdateStatus updates the status of a datastore
+	UpdateStatus(ctx context.Context, datastore *models.Datastore, status models.DatastoreStatus) (*models.Datastore, error)
 }

+ 27 - 0
internal/repository/gorm/datastore.go

@@ -126,3 +126,30 @@ func (repo *DatastoreRepository) Delete(ctx context.Context, datastore *models.D
 
 	return datastore, nil
 }
+
+// UpdateStatus updates the status of a datastore
+func (repo *DatastoreRepository) UpdateStatus(ctx context.Context, datastore *models.Datastore, status models.DatastoreStatus) (*models.Datastore, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-update-datastore-status")
+	defer span.End()
+
+	if datastore == nil {
+		return nil, telemetry.Error(ctx, span, nil, "datastore is nil")
+	}
+
+	if datastore.ID == uuid.Nil {
+		return nil, telemetry.Error(ctx, span, nil, "datastore id is nil")
+	}
+
+	if status == "" {
+		return nil, telemetry.Error(ctx, span, nil, "status is empty")
+	}
+
+	datastore.Status = status
+	datastore.UpdatedAt = time.Now().UTC()
+
+	if err := repo.db.Save(datastore).Error; err != nil {
+		return nil, telemetry.Error(ctx, span, err, "error updating datastore status")
+	}
+
+	return datastore, nil
+}

+ 5 - 0
internal/repository/test/datastore.go

@@ -37,3 +37,8 @@ func (repo *DatastoreRepository) ListByProjectID(ctx context.Context, projectID
 func (repo *DatastoreRepository) Delete(ctx context.Context, datastore *models.Datastore) (*models.Datastore, error) {
 	return nil, errors.New("cannot write database")
 }
+
+// UpdateStatus updates the status of a datastore
+func (repo *DatastoreRepository) UpdateStatus(ctx context.Context, datastore *models.Datastore, status models.DatastoreStatus) (*models.Datastore, error) {
+	return nil, errors.New("cannot write database")
+}