Browse Source

upstash frontend (#4641)

Feroze Mohideen 2 năm trước cách đây
mục cha
commit
82db8f4afe

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

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

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

@@ -13,7 +13,7 @@ import (
 	"github.com/porter-dev/porter/internal/telemetry"
 )
 
-// ListNeonIntegrationsHandler is a struct for listing all noen integrations for a given project
+// ListNeonIntegrationsHandler is a struct for listing all neon integrations for a given project
 type ListNeonIntegrationsHandler struct {
 	handlers.PorterHandlerReadWriter
 }

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

@@ -131,6 +131,7 @@ func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 
 	oauthInt := integrations.UpstashIntegration{
 		SharedOAuthModel: integrations.SharedOAuthModel{
+			ClientID:     []byte(p.Config().UpstashConf.ClientID),
 			AccessToken:  []byte(token.AccessToken),
 			RefreshToken: []byte(token.RefreshToken),
 			Expiry:       token.Expiry,

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

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

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

@@ -5,6 +5,7 @@ import (
 
 	"github.com/porter-dev/porter/api/server/handlers/cloud_provider"
 	"github.com/porter-dev/porter/api/server/handlers/neon_integration"
+	"github.com/porter-dev/porter/api/server/handlers/upstash_integration"
 
 	"github.com/porter-dev/porter/api/server/handlers/deployment_target"
 
@@ -2049,5 +2050,32 @@ func getProjectRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/upstash-integrations -> apiContract.NewListUpstashIntegrationsHandler
+	listUpstashIntegrationsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/upstash-integrations",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listUpstashIntegrationsHandler := upstash_integration.NewListUpstashIntegrationsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+	routes = append(routes, &router.Route{
+		Endpoint: listUpstashIntegrationsEndpoint,
+		Handler:  listUpstashIntegrationsHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

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

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

+ 10 - 0
dashboard/src/lib/databases/types.ts

@@ -37,6 +37,7 @@ const datastoreTypeValidator = z.enum([
   "MANAGED_REDIS",
   "MANAGED_POSTGRES",
   "NEON",
+  "UPSTASH",
 ]);
 const datastoreEngineValidator = z.enum([
   "UNKNOWN",
@@ -114,6 +115,10 @@ export const DATASTORE_TYPE_NEON: DatastoreType = {
   name: "NEON" as const,
   displayName: "Neon",
 };
+export const DATASTORE_TYPE_UPSTASH: DatastoreType = {
+  name: "UPSTASH" as const,
+  displayName: "Upstash",
+};
 
 export type DatastoreState = {
   state: z.infer<typeof datastoreValidator>["status"];
@@ -334,6 +339,10 @@ const neonValidator = z.object({
   type: z.literal("neon"),
 });
 
+const upstashValidator = z.object({
+  type: z.literal("upstash"),
+});
+
 export const dbFormValidator = z.object({
   name: z
     .string()
@@ -355,6 +364,7 @@ export const dbFormValidator = z.object({
     managedRedisConfigValidator,
     managedPostgresConfigValidator,
     neonValidator,
+    upstashValidator,
   ]),
   clusterId: z.number(),
 });

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

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

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

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

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

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

+ 39 - 1
dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx

@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useContext, useState } from "react";
 import styled from "styled-components";
 import { match } from "ts-pattern";
 
@@ -7,15 +7,23 @@ import Icon from "components/porter/Icon";
 import Spacer from "components/porter/Spacer";
 import StatusDot from "components/porter/StatusDot";
 import Text from "components/porter/Text";
+import Tooltip from "components/porter/Tooltip";
 
+import { Context } from "shared/Context";
 import { readableDate } from "shared/string_utils";
+import trash from "assets/trash.png";
 
 import { useDatastoreContext } from "./DatabaseContextProvider";
+import { DeleteDatastoreModal } from "./tabs/SettingsTab";
 import EngineTag from "./tags/EngineTag";
 
 const DatabaseHeader: React.FC = () => {
   const { datastore } = useDatastoreContext();
 
+  const [showDeleteDatastoreModal, setShowDeleteDatastoreModal] =
+    useState(false);
+  const { user } = useContext(Context);
+
   return (
     <>
       <Container row style={{ width: "100%" }}>
@@ -28,6 +36,29 @@ const DatabaseHeader: React.FC = () => {
             <Container row>
               <EngineTag engine={datastore.template.engine} heightPixels={15} />
             </Container>
+            {user?.isPorterUser && (
+              <>
+                <Spacer inline x={1} />
+                <Tooltip
+                  content={
+                    <Text>
+                      Delete this datastore and all of its resources (only
+                      visible to Porter operators).
+                    </Text>
+                  }
+                  position={"right"}
+                >
+                  <div
+                    style={{ cursor: "pointer" }}
+                    onClick={() => {
+                      setShowDeleteDatastoreModal(true);
+                    }}
+                  >
+                    <Icon src={trash} height={"15px"} />
+                  </div>
+                </Tooltip>
+              </>
+            )}
           </Container>
           {match(datastore.status)
             .with("AVAILABLE", () => (
@@ -51,6 +82,13 @@ const DatabaseHeader: React.FC = () => {
         </div>
         <Spacer y={0.5} />
       </CreatedAtContainer>
+      {showDeleteDatastoreModal && (
+        <DeleteDatastoreModal
+          onClose={() => {
+            setShowDeleteDatastoreModal(false);
+          }}
+        />
+      )}
     </>
   );
 };

+ 25 - 1
dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx

@@ -12,10 +12,14 @@ import { useDatastoreList } from "lib/hooks/useDatabaseList";
 import { useDatastore } from "lib/hooks/useDatastore";
 import { useIntercom } from "lib/hooks/useIntercom";
 import { useNeon } from "lib/hooks/useNeon";
+import { useUpstash } from "lib/hooks/useUpstash";
 
 import { Context } from "shared/Context";
 
-import NeonIntegrationModal from "./shared/NeonIntegrationModal";
+import {
+  NeonIntegrationModal,
+  UpstashIntegrationModal,
+} from "./shared/NeonIntegrationModal";
 
 // todo(ianedwards): refactor button to use more predictable state
 export type UpdateDatastoreButtonProps = {
@@ -53,8 +57,11 @@ const DatastoreFormContextProvider: React.FC<
 
   const [updateDatastoreError, setUpdateDatastoreError] = useState<string>("");
   const { getNeonIntegrations } = useNeon();
+  const { getUpstashIntegrations } = useUpstash();
   const [showNeonIntegrationModal, setShowNeonIntegrationModal] =
     useState(false);
+  const [showUpstashIntegrationModal, setShowUpstashIntegrationModal] =
+    useState(false);
 
   const { showIntercomWithMessage } = useIntercom();
 
@@ -117,6 +124,16 @@ const DatastoreFormContextProvider: React.FC<
           return;
         }
       }
+      if (data.config.type === "upstash") {
+        const integrations = await getUpstashIntegrations({
+          projectId: currentProject.id,
+        });
+        if (integrations.length === 0) {
+          setShowUpstashIntegrationModal(true);
+          return;
+        }
+      }
+
       await createDatastore(data);
       history.push(`/datastores/${data.name}`);
     } catch (err) {
@@ -154,6 +171,13 @@ const DatastoreFormContextProvider: React.FC<
           }}
         />
       )}
+      {showUpstashIntegrationModal && (
+        <UpstashIntegrationModal
+          onClose={() => {
+            setShowUpstashIntegrationModal(false);
+          }}
+        />
+      )}
     </DatastoreFormContext.Provider>
   );
 };

+ 9 - 2
dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx

@@ -2,7 +2,10 @@ import React, { useMemo } from "react";
 
 import StatusBar from "components/porter/StatusBar";
 
-import { DATASTORE_TEMPLATE_NEON } from "./constants";
+import {
+  DATASTORE_TEMPLATE_NEON,
+  DATASTORE_TEMPLATE_UPSTASH,
+} from "./constants";
 import { useDatastoreContext } from "./DatabaseContextProvider";
 
 const DatastoreProvisioningIndicator: React.FC = () => {
@@ -40,9 +43,13 @@ const DatastoreProvisioningIndicator: React.FC = () => {
       return { percentCompleted, title, titleDescriptor, isCreating };
     }, [datastore]);
 
+  // TODO: refactor this so the template has the setup/deletion time
   const subtitle = useMemo(() => {
     return `${isCreating ? "Setup" : "Deletion"} can take up to ${
-      datastore.template === DATASTORE_TEMPLATE_NEON ? 5 : 20
+      datastore.template === DATASTORE_TEMPLATE_NEON ||
+      datastore.template === DATASTORE_TEMPLATE_UPSTASH
+        ? 5
+        : 20
     } minutes. You can close this window and come back later.`;
   }, [datastore]);
 

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

@@ -15,6 +15,7 @@ import {
   DATASTORE_TYPE_MANAGED_REDIS,
   DATASTORE_TYPE_NEON,
   DATASTORE_TYPE_RDS,
+  DATASTORE_TYPE_UPSTASH,
   type DatastoreEngine,
   type DatastoreTemplate,
 } from "lib/databases/types";
@@ -25,6 +26,7 @@ import infra from "assets/cluster.svg";
 import neon from "assets/neon.svg";
 import postgresql from "assets/postgresql.svg";
 import redis from "assets/redis.svg";
+import upstash from "assets/upstash.svg";
 
 import ConfigurationTab from "./tabs/ConfigurationTab";
 import ConnectTab from "./tabs/ConnectTab";
@@ -418,6 +420,41 @@ export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({
   ],
 });
 
+export const DATASTORE_TEMPLATE_UPSTASH: DatastoreTemplate = Object.freeze({
+  name: "Upstash",
+  displayName: "Upstash",
+  highLevelType: DATASTORE_ENGINE_REDIS,
+  type: DATASTORE_TYPE_UPSTASH,
+  engine: DATASTORE_ENGINE_REDIS,
+  supportedEngineVersions: [],
+  icon: upstash as string,
+  description:
+    "A fully managed, serverless data store optimized for Redis. Upstash separates storage and compute to deliver auto-scaling, on-demand databases, and per-request pricing with a focus on low latency and high availability.",
+  disabled: true,
+  instanceTiers: [],
+  creationStateProgression: [
+    DATASTORE_STATE_CREATING,
+    DATASTORE_STATE_AVAILABLE,
+  ],
+  deletionStateProgression: [
+    DATASTORE_STATE_AWAITING_DELETION,
+    DATASTORE_STATE_DELETING_RECORD,
+    DATASTORE_STATE_DELETED,
+  ],
+  tabs: [
+    {
+      name: "connectivity",
+      displayName: "Connectivity",
+      component: PublicDatastoreConnectTab,
+    },
+    {
+      name: "settings",
+      displayName: "Settings",
+      component: SettingsTab,
+    },
+  ],
+});
+
 export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
   DATASTORE_TEMPLATE_AWS_RDS,
   DATASTORE_TEMPLATE_AWS_AURORA,
@@ -425,4 +462,5 @@ export const SUPPORTED_DATASTORE_TEMPLATES: DatastoreTemplate[] = [
   DATASTORE_TEMPLATE_MANAGED_POSTGRES,
   DATASTORE_TEMPLATE_MANAGED_REDIS,
   DATASTORE_TEMPLATE_NEON,
+  DATASTORE_TEMPLATE_UPSTASH,
 ];

+ 23 - 20
dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx

@@ -24,6 +24,7 @@ import {
   DATASTORE_ENGINE_POSTGRES,
   DATASTORE_ENGINE_REDIS,
   DATASTORE_TEMPLATE_NEON,
+  DATASTORE_TEMPLATE_UPSTASH,
   SUPPORTED_DATASTORE_TEMPLATES,
 } from "../constants";
 import { useDatastoreFormContext } from "../DatastoreFormContextProvider";
@@ -48,21 +49,14 @@ const SandboxDatastoreForm: React.FC = () => {
   const { updateDatastoreButtonProps } = useDatastoreFormContext();
 
   const availableEngines: BlockSelectOption[] = useMemo(() => {
-    return [
-      DATASTORE_ENGINE_POSTGRES,
-      {
-        ...DATASTORE_ENGINE_REDIS,
-        disabledOpts: {
-          tooltipText: "Coming soon!",
-        },
-      },
-    ];
+    return [DATASTORE_ENGINE_POSTGRES, DATASTORE_ENGINE_REDIS];
   }, [watchClusterId]);
 
   const availableHostTypes: BlockSelectOption[] = useMemo(() => {
-    const options = [DATASTORE_TEMPLATE_NEON].filter(
-      (t) => t.highLevelType.name === watchEngine
-    );
+    const options = [
+      DATASTORE_TEMPLATE_NEON,
+      DATASTORE_TEMPLATE_UPSTASH,
+    ].filter((t) => t.highLevelType.name === watchEngine);
     return options;
   }, [watchEngine]);
 
@@ -167,14 +161,23 @@ const SandboxDatastoreForm: React.FC = () => {
                         return;
                       }
                       setTemplate(templateMatch);
-                      match(templateMatch).with(
-                        {
-                          name: DATASTORE_TEMPLATE_NEON.name,
-                        },
-                        () => {
-                          setValue("config.type", "neon");
-                        }
-                      );
+                      match(templateMatch)
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_NEON.name,
+                          },
+                          () => {
+                            setValue("config.type", "neon");
+                          }
+                        )
+                        .with(
+                          {
+                            name: DATASTORE_TEMPLATE_UPSTASH.name,
+                          },
+                          () => {
+                            setValue("config.type", "upstash");
+                          }
+                        );
                       setCurrentStep(4);
                     }}
                   />

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

@@ -13,6 +13,7 @@ import {
   DATASTORE_TYPE_MANAGED_REDIS,
   DATASTORE_TYPE_NEON,
   DATASTORE_TYPE_RDS,
+  DATASTORE_TYPE_UPSTASH,
   type DatastoreConnectionInfo,
   type DatastoreTemplate,
 } from "lib/databases/types";
@@ -39,6 +40,11 @@ const ConnectionInfo: React.FC<Props> = ({ connectionInfo, template }) => {
             () =>
               `rediss://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0?ssl_cert_reqs=CERT_REQUIRED`
           )
+          .with(
+            { type: DATASTORE_TYPE_UPSTASH },
+            () =>
+              `rediss://default:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0`
+          )
           .with(
             { type: DATASTORE_TYPE_MANAGED_REDIS },
             () =>

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

@@ -7,6 +7,7 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { useAuthWindow } from "lib/hooks/useAuthWindow";
 import { useNeon } from "lib/hooks/useNeon";
+import { useUpstash } from "lib/hooks/useUpstash";
 
 import { useDatastoreFormContext } from "../DatastoreFormContextProvider";
 
@@ -14,7 +15,7 @@ type Props = {
   onClose: () => void;
 };
 
-const NeonIntegrationModal: React.FC<Props> = ({ onClose }) => {
+export const NeonIntegrationModal: React.FC<Props> = ({ onClose }) => {
   const { projectId } = useDatastoreFormContext();
   const { getNeonIntegrations } = useNeon();
   const { openAuthWindow } = useAuthWindow({
@@ -57,4 +58,45 @@ const NeonIntegrationModal: React.FC<Props> = ({ onClose }) => {
   );
 };
 
-export default NeonIntegrationModal;
+export const UpstashIntegrationModal: React.FC<Props> = ({ onClose }) => {
+  const { projectId } = useDatastoreFormContext();
+  const { getUpstashIntegrations } = useUpstash();
+  const { openAuthWindow } = useAuthWindow({
+    authUrl: `/api/projects/${projectId}/oauth/upstash`,
+  });
+
+  const upstashIntegrationsResp = useQuery(
+    ["getUpstashIntegrations", projectId],
+    async () => {
+      const integrations = await getUpstashIntegrations({
+        projectId,
+      });
+      return integrations;
+    },
+    {
+      enabled: !!projectId,
+      refetchInterval: 1000,
+    }
+  );
+  useEffect(() => {
+    if (
+      upstashIntegrationsResp.isSuccess &&
+      upstashIntegrationsResp.data.length > 0
+    ) {
+      onClose();
+    }
+  }, [upstashIntegrationsResp]);
+
+  return (
+    <Modal closeModal={onClose} width={"800px"}>
+      <Text size={16}>Integrate Upstash</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        To continue, you must authenticate with Upstash.{" "}
+        <Link target="_blank" onClick={openAuthWindow} hasunderline>
+          Authorize Porter to create Upstash datastores on your behalf
+        </Link>
+      </Text>
+    </Modal>
+  );
+};

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

@@ -22,7 +22,6 @@ const SettingsTab: React.FC = () => {
     useState(false);
 
   const { datastore } = useDatastoreContext();
-  const { deleteDatastore } = useDatastore();
 
   return (
     <div>
@@ -49,9 +48,6 @@ const SettingsTab: React.FC = () => {
           onClose={() => {
             setShowDeleteDatastoreModal(false);
           }}
-          onSubmit={async () => {
-            await deleteDatastore(datastore.name);
-          }}
         />
       )}
     </div>
@@ -61,15 +57,14 @@ const SettingsTab: React.FC = () => {
 export default SettingsTab;
 
 type DeleteDatastoreModalProps = {
-  onSubmit: () => Promise<void>;
   onClose: () => void;
 };
 
-const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
-  onSubmit,
+export const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
   onClose,
 }) => {
   const { datastore } = useDatastoreContext();
+  const { deleteDatastore } = useDatastore();
 
   const [inputtedDatastoreName, setInputtedDatastoreName] =
     useState<string>("");
@@ -79,7 +74,7 @@ const DeleteDatastoreModal: React.FC<DeleteDatastoreModalProps> = ({
   const confirmDeletion = async (): Promise<void> => {
     setIsSubmitting(true);
     try {
-      await onSubmit();
+      await deleteDatastore(datastore.name);
       onClose();
     } catch (err) {
       setDeleteDatastoreError(

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

@@ -2068,6 +2068,13 @@ const getNeonIntegrations = baseApi<{}, { projectId: number }>(
   }
 );
 
+const getUpstashIntegrations = baseApi<{}, { projectId: number }>(
+  "GET",
+  ({ projectId }) => {
+    return `/api/projects/${projectId}/upstash-integrations`;
+  }
+);
+
 const getRevisions = baseApi<
   {},
   { id: number; cluster_id: number; namespace: string; name: string }
@@ -2889,7 +2896,13 @@ const getDatastoreCredential = baseApi<
 const updateDatastore = baseApi<
   {
     name: string;
-    type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON";
+    type:
+      | "RDS"
+      | "ELASTICACHE"
+      | "MANAGED-POSTGRES"
+      | "MANAGED-REDIS"
+      | "NEON"
+      | "UPSTASH";
     engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS";
 
     values: any;
@@ -3895,6 +3908,7 @@ export default {
   getRepoIntegrations,
   getSlackIntegrations,
   getNeonIntegrations,
+  getUpstashIntegrations,
   getRepos,
   getRevisions,
   getTemplateInfo,

+ 1 - 1
go.mod

@@ -88,7 +88,7 @@ require (
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
 	github.com/ory/client-go v1.9.0
-	github.com/porter-dev/api-contracts v0.2.159
+	github.com/porter-dev/api-contracts v0.2.161
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1565,6 +1565,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4=
 github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
+github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk=
+github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 72 - 0
internal/repository/gorm/upstash.go

@@ -42,6 +42,31 @@ func (repo *UpstashIntegrationRepository) Insert(
 	return created, nil
 }
 
+// Integrations returns all upstash integrations for a given project
+func (repo *UpstashIntegrationRepository) Integrations(
+	ctx context.Context, projectID uint,
+) ([]ints.UpstashIntegration, error) {
+	ctx, span := telemetry.NewSpan(ctx, "gorm-list-upstash-integrations")
+	defer span.End()
+
+	var integrations []ints.UpstashIntegration
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&integrations).Error; err != nil {
+		return integrations, telemetry.Error(ctx, span, err, "failed to list upstash integrations")
+	}
+
+	for i, integration := range integrations {
+		decrypted, err := repo.DecryptUpstashIntegration(integration, repo.key)
+		if err != nil {
+			return integrations, telemetry.Error(ctx, span, err, "failed to decrypt")
+		}
+
+		integrations[i] = decrypted
+	}
+
+	return integrations, nil
+}
+
 // EncryptUpstashIntegration will encrypt the upstash integration data before
 // writing to the DB
 func (repo *UpstashIntegrationRepository) EncryptUpstashIntegration(
@@ -88,3 +113,50 @@ func (repo *UpstashIntegrationRepository) EncryptUpstashIntegration(
 
 	return encrypted, nil
 }
+
+// DecryptUpstashIntegration will decrypt the upstash integration data before
+// returning it from the DB
+func (repo *UpstashIntegrationRepository) DecryptUpstashIntegration(
+	upstashInt ints.UpstashIntegration,
+	key *[32]byte,
+) (ints.UpstashIntegration, error) {
+	decrypted := upstashInt
+
+	if len(decrypted.ClientID) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.ClientID, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.ClientID = plaintext
+	}
+
+	if len(decrypted.AccessToken) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.AccessToken, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.AccessToken = plaintext
+	}
+
+	if len(decrypted.RefreshToken) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.RefreshToken, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.RefreshToken = plaintext
+	}
+
+	if len(decrypted.DeveloperApiKey) > 0 {
+		plaintext, err := encryption.Decrypt(decrypted.DeveloperApiKey, key)
+		if err != nil {
+			return decrypted, err
+		}
+
+		decrypted.DeveloperApiKey = plaintext
+	}
+
+	return decrypted, nil
+}

+ 4 - 0
internal/repository/test/upstash.go

@@ -16,3 +16,7 @@ func NewUpstashIntegrationRepository(canQuery bool) repository.UpstashIntegratio
 func (s *UpstashIntegrationRepository) Insert(ctx context.Context, upstashInt ints.UpstashIntegration) (ints.UpstashIntegration, error) {
 	panic("not implemented") // TODO: Implement
 }
+
+func (s *UpstashIntegrationRepository) Integrations(ctx context.Context, projectID uint) ([]ints.UpstashIntegration, error) {
+	panic("not implemented") // TODO: Implement
+}

+ 2 - 0
internal/repository/upstash.go

@@ -10,4 +10,6 @@ import (
 type UpstashIntegrationRepository interface {
 	// Insert creates a new upstash integration
 	Insert(ctx context.Context, upstashInt ints.UpstashIntegration) (ints.UpstashIntegration, error)
+	// Integrations returns all upstash integrations belonging to a project
+	Integrations(ctx context.Context, projectID uint) ([]ints.UpstashIntegration, error)
 }