diff --git a/api/server/handlers/addons/tailscale_services.go b/api/server/handlers/addons/tailscale_services.go index e1fbb68c02..ccc2573927 100644 --- a/api/server/handlers/addons/tailscale_services.go +++ b/api/server/handlers/addons/tailscale_services.go @@ -79,7 +79,7 @@ func (c *TailscaleServicesHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - var services []TailscaleService + services := make([]TailscaleService, 0) for _, svc := range svcList.Items { var port int if len(svc.Spec.Ports) > 0 { diff --git a/api/server/handlers/datastore/update.go b/api/server/handlers/datastore/update.go index 755cfcca0d..7a2a335387 100644 --- a/api/server/handlers/datastore/update.go +++ b/api/server/handlers/datastore/update.go @@ -187,6 +187,9 @@ func (h *UpdateDatastoreHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques MasterUserPasswordLiteral: pointer.String(datastoreValues.Config.MasterUserPassword), }, } + case "NEON": + datastoreProto.Kind = porterv1.EnumDatastoreKind_ENUM_DATASTORE_KIND_NEON + datastoreProto.KindValues = &porterv1.ManagedDatastore_NeonKind{} default: err = telemetry.Error(ctx, span, nil, "invalid datastore type") h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) diff --git a/api/server/handlers/neon_integration/list.go b/api/server/handlers/neon_integration/list.go new file mode 100644 index 0000000000..e250e6e587 --- /dev/null +++ b/api/server/handlers/neon_integration/list.go @@ -0,0 +1,69 @@ +package neon_integration + +import ( + "net/http" + "time" + + "github.com/porter-dev/porter/api/server/handlers" + "github.com/porter-dev/porter/api/server/shared" + "github.com/porter-dev/porter/api/server/shared/apierrors" + "github.com/porter-dev/porter/api/server/shared/config" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/models" + "github.com/porter-dev/porter/internal/telemetry" +) + +// ListNeonIntegrationsHandler is a struct for listing all noen integrations for a given project +type ListNeonIntegrationsHandler struct { + handlers.PorterHandlerReadWriter +} + +// NewListNeonIntegrationsHandler constructs a ListNeonIntegrationsHandler +func NewListNeonIntegrationsHandler( + config *config.Config, + decoderValidator shared.RequestDecoderValidator, + writer shared.ResultWriter, +) *ListNeonIntegrationsHandler { + return &ListNeonIntegrationsHandler{ + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + } +} + +// NeonIntegration describes a neon integration +type NeonIntegration struct { + CreatedAt time.Time `json:"created_at"` +} + +// ListNeonIntegrationsResponse describes the list neon integrations response body +type ListNeonIntegrationsResponse struct { + // Integrations is a list of neon integrations + Integrations []NeonIntegration `json:"integrations"` +} + +// ServeHTTP returns a list of neon integrations associated with the specified project +func (h *ListNeonIntegrationsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx, span := telemetry.NewSpan(r.Context(), "serve-list-neon-integrations") + defer span.End() + + project, _ := ctx.Value(types.ProjectScope).(*models.Project) + + resp := ListNeonIntegrationsResponse{} + integrationList := make([]NeonIntegration, 0) + + integrations, err := h.Repo().NeonIntegration().Integrations(ctx, project.ID) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting datastores") + h.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) + return + } + + for _, int := range integrations { + integrationList = append(integrationList, NeonIntegration{ + CreatedAt: int.CreatedAt, + }) + } + + resp.Integrations = integrationList + + h.WriteResult(w, r, resp) +} diff --git a/api/server/handlers/oauth_callback/neon.go b/api/server/handlers/oauth_callback/neon.go index ccf2726ce5..4c6116a466 100644 --- a/api/server/handlers/oauth_callback/neon.go +++ b/api/server/handlers/oauth_callback/neon.go @@ -93,6 +93,7 @@ func (p *OAuthCallbackNeonHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ oauthInt := integrations.NeonIntegration{ SharedOAuthModel: integrations.SharedOAuthModel{ + ClientID: []byte(p.Config().NeonConf.ClientID), AccessToken: []byte(token.AccessToken), RefreshToken: []byte(token.RefreshToken), Expiry: token.Expiry, diff --git a/api/server/router/project.go b/api/server/router/project.go index ebecb912b4..2b30b03103 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/porter-dev/porter/api/server/handlers/cloud_provider" + "github.com/porter-dev/porter/api/server/handlers/neon_integration" "github.com/porter-dev/porter/api/server/handlers/deployment_target" @@ -2021,5 +2022,32 @@ func getProjectRoutes( Router: r, }) + // GET /api/projects/{project_id}/neon-integrations -> apiContract.NewListNeonIntegrationsHandler + listNeonIntegrationsEndpoint := factory.NewAPIEndpoint( + &types.APIRequestMetadata{ + Verb: types.APIVerbGet, + Method: types.HTTPVerbGet, + Path: &types.Path{ + Parent: basePath, + RelativePath: relPath + "/neon-integrations", + }, + Scopes: []types.PermissionScope{ + types.UserScope, + types.ProjectScope, + }, + }, + ) + + listNeonIntegrationsHandler := neon_integration.NewListNeonIntegrationsHandler( + config, + factory.GetDecoderValidator(), + factory.GetResultWriter(), + ) + routes = append(routes, &router.Route{ + Endpoint: listNeonIntegrationsEndpoint, + Handler: listNeonIntegrationsHandler, + Router: r, + }) + return routes, newPath } diff --git a/dashboard/src/assets/neon.svg b/dashboard/src/assets/neon.svg new file mode 100644 index 0000000000..16d34cbc85 --- /dev/null +++ b/dashboard/src/assets/neon.svg @@ -0,0 +1,20 @@ + + + Neon + + + + + + + + + + + + + + + + + diff --git a/dashboard/src/components/porter/BlockSelect.tsx b/dashboard/src/components/porter/BlockSelect.tsx index d48bb78cdf..0f1934ba5e 100644 --- a/dashboard/src/components/porter/BlockSelect.tsx +++ b/dashboard/src/components/porter/BlockSelect.tsx @@ -100,6 +100,7 @@ const Block = styled.div<{ selected?: boolean; disabled?: boolean }>` display: flex; flex-direction: column; height: 100%; + width: 100%; align-items: left; user-select: none; font-size: 13px; diff --git a/dashboard/src/lib/databases/types.ts b/dashboard/src/lib/databases/types.ts index b3fb0faa51..0d49a168a3 100644 --- a/dashboard/src/lib/databases/types.ts +++ b/dashboard/src/lib/databases/types.ts @@ -36,6 +36,7 @@ const datastoreTypeValidator = z.enum([ "ELASTICACHE", "MANAGED_REDIS", "MANAGED_POSTGRES", + "NEON", ]); const datastoreEngineValidator = z.enum([ "UNKNOWN", @@ -109,6 +110,10 @@ export const DATASTORE_TYPE_MANAGED_REDIS: DatastoreType = { name: "MANAGED_REDIS" as const, displayName: "Managed Redis", }; +export const DATASTORE_TYPE_NEON: DatastoreType = { + name: "NEON" as const, + displayName: "Neon", +}; export type DatastoreState = { state: z.infer["status"]; @@ -159,6 +164,19 @@ export const DATASTORE_STATE_DELETED: DatastoreState = { displayName: "Wrapping up", }; +export type DatastoreTab = { + name: string; + displayName: string; + component: React.FC; + isOnlyForPorterOperators?: boolean; +}; + +export const DEFAULT_DATASTORE_TAB = { + name: "configuration", + displayName: "Configuration", + component: () => null, +}; + export type DatastoreTemplate = { highLevelType: DatastoreEngine; // this was created so that rds aurora postgres and rds postgres can be grouped together type: DatastoreType; @@ -170,9 +188,9 @@ export type DatastoreTemplate = { disabled: boolean; instanceTiers: ResourceOption[]; supportedEngineVersions: EngineVersion[]; - formTitle: string; creationStateProgression: DatastoreState[]; deletionStateProgression: DatastoreState[]; + tabs: DatastoreTab[]; // this what is rendered on the dashboard after the datastore is deployed }; const instanceTierValidator = z.enum([ @@ -312,6 +330,10 @@ const managedRedisConfigValidator = z.object({ .default(1), }); +const neonValidator = z.object({ + type: z.literal("neon"), +}); + export const dbFormValidator = z.object({ name: z .string() @@ -332,6 +354,7 @@ export const dbFormValidator = z.object({ elasticacheRedisConfigValidator, managedRedisConfigValidator, managedPostgresConfigValidator, + neonValidator, ]), clusterId: z.number(), }); diff --git a/dashboard/src/lib/hooks/useAddon.ts b/dashboard/src/lib/hooks/useAddon.ts index f5d37fb043..7d32b34f60 100644 --- a/dashboard/src/lib/hooks/useAddon.ts +++ b/dashboard/src/lib/hooks/useAddon.ts @@ -139,7 +139,7 @@ export const useAddonList = ({ "monitoring", "porter-agent-system", "external-secrets", - "infisical" + "infisical", ].includes(a.namespace ?? ""); }); }, @@ -552,7 +552,7 @@ export const useAddonLogs = ({ projectId?: number; deploymentTarget: DeploymentTarget; addon?: ClientAddon; -}): { logs: Log[]; refresh: () => void; isInitializing: boolean } => { +}): { logs: Log[]; refresh: () => Promise; isInitializing: boolean } => { const [logs, setLogs] = useState([]); const logsBufferRef = useRef([]); const { newWebsocket, openWebsocket, closeAllWebsockets } = useWebsockets(); diff --git a/dashboard/src/lib/hooks/useAuthWindow.ts b/dashboard/src/lib/hooks/useAuthWindow.ts new file mode 100644 index 0000000000..922f4921c0 --- /dev/null +++ b/dashboard/src/lib/hooks/useAuthWindow.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; + +/** + * Hook to open an authentication window at a given url. + * Once the auth flow redirects back to Porter, the window is closed. + */ +export const useAuthWindow = ({ + authUrl, +}: { + authUrl: string; +}): { + openAuthWindow: () => void; +} => { + const [authWindow, setAuthWindow] = useState(null); + + const openAuthWindow = (): void => { + const windowObjectReference = window.open( + authUrl, + "porterAuthWindow", + "width=600,height=700,left=200,top=200" + ); + setAuthWindow(windowObjectReference); + }; + + useEffect(() => { + const interval = setInterval(() => { + if (authWindow) { + try { + if ( + authWindow.location.hostname.includes("dashboard.getporter.dev") || + authWindow.location.hostname.includes("localhost") + ) { + authWindow.close(); + setAuthWindow(null); + clearInterval(interval); + } + } catch (e) { + console.log("Error accessing the authentication window.", e); + } + } + }, 1000); + + return () => { + clearInterval(interval); + if (authWindow) { + authWindow.close(); + } + }; + }, [authWindow]); + + return { openAuthWindow }; +}; diff --git a/dashboard/src/lib/hooks/useDatastore.ts b/dashboard/src/lib/hooks/useDatastore.ts index cdaa32947a..9031ca5a37 100644 --- a/dashboard/src/lib/hooks/useDatastore.ts +++ b/dashboard/src/lib/hooks/useDatastore.ts @@ -21,7 +21,7 @@ type DatastoreHook = { }; type CreateDatastoreInput = { name: string; - type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS"; + type: "RDS" | "ELASTICACHE" | "MANAGED-POSTGRES" | "MANAGED-REDIS" | "NEON"; engine: "POSTGRES" | "AURORA-POSTGRES" | "REDIS"; values: object; }; @@ -134,6 +134,19 @@ const clientDbToCreateInput = (values: DbFormData): CreateDatastoreInput => { }; } ) + .with( + { config: { type: "neon" } }, + (values): CreateDatastoreInput => ({ + name: values.name, + values: { + config: { + name: values.name, + }, + }, + type: "NEON", + engine: "POSTGRES", + }) + ) .exhaustive(); }; diff --git a/dashboard/src/lib/hooks/useNeon.ts b/dashboard/src/lib/hooks/useNeon.ts new file mode 100644 index 0000000000..07829078ac --- /dev/null +++ b/dashboard/src/lib/hooks/useNeon.ts @@ -0,0 +1,41 @@ +import { z } from "zod"; + +import { + neonIntegrationValidator, + type ClientNeonIntegration, +} from "lib/neon/types"; + +import api from "shared/api"; + +type TUseNeon = { + getNeonIntegrations: ({ + projectId, + }: { + projectId: number; + }) => Promise; +}; +export const useNeon = (): TUseNeon => { + const getNeonIntegrations = async ({ + projectId, + }: { + projectId: number; + }): Promise => { + const response = await api.getNeonIntegrations( + "", + {}, + { + projectId, + } + ); + + const results = await z + .object({ integrations: z.array(neonIntegrationValidator) }) + .parseAsync(response.data); + + return results.integrations; + }; + + return { + getNeonIntegrations, + }; +}; diff --git a/dashboard/src/lib/neon/types.ts b/dashboard/src/lib/neon/types.ts new file mode 100644 index 0000000000..6c8047106e --- /dev/null +++ b/dashboard/src/lib/neon/types.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; + +export const neonIntegrationValidator = z.object({ + created_at: z.string(), +}); +export type ClientNeonIntegration = z.infer; diff --git a/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx b/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx index 14ff0119d0..a76964b051 100644 --- a/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx +++ b/dashboard/src/main/home/add-on-dashboard/AddonTabs.tsx @@ -9,10 +9,6 @@ import Banner from "components/porter/Banner"; import Spacer from "components/porter/Spacer"; import TabSelector from "components/TabSelector"; import { type ClientAddon } from "lib/addons"; -import { - DEFAULT_ADDON_TAB, - SUPPORTED_ADDON_TEMPLATES, -} from "lib/addons/template"; import { Context } from "shared/Context"; @@ -37,38 +33,24 @@ const AddonTabs: React.FC = ({ tabParam }) => { reset(addon); }, [addon]); - const addonTemplate = useMemo(() => { - return SUPPORTED_ADDON_TEMPLATES.find( - (template) => template.type === addon.config.type - ); - }, [addon]); - const tabs = useMemo(() => { - if (addonTemplate) { - return addonTemplate.tabs - .filter( - (t) => - !t.isOnlyForPorterOperators || - (t.isOnlyForPorterOperators && user.isPorterUser) - ) - .map((tab) => ({ - label: tab.displayName, - value: tab.name, - })); - } - return [ - { - label: DEFAULT_ADDON_TAB.displayName, - value: DEFAULT_ADDON_TAB.name, - }, - ]; - }, [addonTemplate]); + return addon.template.tabs + .filter( + (t) => + !t.isOnlyForPorterOperators || + (t.isOnlyForPorterOperators && user.isPorterUser) + ) + .map((tab) => ({ + label: tab.displayName, + value: tab.name, + })); + }, [addon.template]); const currentTab = useMemo(() => { if (tabParam && tabs.some((tab) => tab.value === tabParam)) { return tabParam; } - return tabs[0].value; + return tabs.length ? tabs[0].value : ""; }, [tabParam, tabs]); return ( @@ -96,7 +78,7 @@ const AddonTabs: React.FC = ({ tabParam }) => { }} /> - {addonTemplate?.tabs + {addon.template.tabs .filter( (t) => !t.isOnlyForPorterOperators || diff --git a/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx index d456d4ba30..3e4f65a520 100644 --- a/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx +++ b/dashboard/src/main/home/add-on-dashboard/tailscale/TailscaleOverview.tsx @@ -45,7 +45,7 @@ const TailscaleOverview: React.FC = () => { const parsed = await z .object({ - services: z.array(tailscaleServiceValidator), + services: z.array(tailscaleServiceValidator).optional().default([]), }) .parseAsync(res.data); diff --git a/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx b/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx index f52cf8bf45..106e63603b 100644 --- a/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx +++ b/dashboard/src/main/home/database-dashboard/DatabaseHeader.tsx @@ -11,7 +11,6 @@ import Text from "components/porter/Text"; import { readableDate } from "shared/string_utils"; import { useDatastoreContext } from "./DatabaseContextProvider"; -import { getDatastoreIcon } from "./icons"; import EngineTag from "./tags/EngineTag"; const DatabaseHeader: React.FC = () => { @@ -22,7 +21,7 @@ const DatabaseHeader: React.FC = () => { - + {datastore.name} diff --git a/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx b/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx index 5fb5c69cec..d05e76b492 100644 --- a/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx +++ b/dashboard/src/main/home/database-dashboard/DatabaseTabs.tsx @@ -1,26 +1,14 @@ -import React, { useMemo } from "react"; +import React, { useContext, useMemo } from "react"; import { useHistory } from "react-router"; import { match } from "ts-pattern"; import Spacer from "components/porter/Spacer"; import TabSelector from "components/TabSelector"; +import { Context } from "shared/Context"; + import { useDatastoreContext } from "./DatabaseContextProvider"; import DatastoreProvisioningIndicator from "./DatastoreProvisioningIndicator"; -import ConfigurationTab from "./tabs/ConfigurationTab"; -import ConnectTab from "./tabs/ConnectTab"; -import MetricsTab from "./tabs/MetricsTab"; -import SettingsTab from "./tabs/SettingsTab"; - -const validTabs = [ - "metrics", - "connect", - "configuration", - "settings", - "connected-apps", -] as const; -const DEFAULT_TAB = "connect"; -type ValidTab = (typeof validTabs)[number]; type DbTabProps = { tabParam?: string; @@ -32,23 +20,27 @@ export type ButtonStatus = "" | "loading" | JSX.Element | "success"; const DatabaseTabs: React.FC = ({ tabParam }) => { const history = useHistory(); const { datastore } = useDatastoreContext(); + const { user } = useContext(Context); + + const tabs = useMemo(() => { + return datastore.template.tabs + .filter( + (t) => + !t.isOnlyForPorterOperators || + (t.isOnlyForPorterOperators && user.isPorterUser) + ) + .map((tab) => ({ + label: tab.displayName, + value: tab.name, + })); + }, [datastore.template]); const currentTab = useMemo(() => { - if (tabParam && validTabs.includes(tabParam as ValidTab)) { - return tabParam as ValidTab; + if (tabParam && tabs.some((tab) => tab.value === tabParam)) { + return tabParam; } - - return DEFAULT_TAB; - }, [tabParam]); - - const tabs = useMemo(() => { - return [ - { label: "Connectivity", value: "connect" }, - // { label: "Connected Apps", value: "connected-apps" }, - { label: "Configuration", value: "configuration" }, - { label: "Settings", value: "settings" }, - ]; - }, []); + return tabs.length ? tabs[0].value : ""; + }, [tabParam, tabs]); if (datastore.status !== "AVAILABLE") { return ; @@ -65,13 +57,17 @@ const DatabaseTabs: React.FC = ({ tabParam }) => { }} /> - {match(currentTab) - .with("connect", () => ) - .with("settings", () => ) - .with("metrics", () => ) - .with("configuration", () => ) - // .with("connected-apps", () => ) - .otherwise(() => null)} + {datastore.template.tabs + .filter( + (t) => + !t.isOnlyForPorterOperators || + (t.isOnlyForPorterOperators && user.isPorterUser) + ) + .map((tab) => + match(currentTab) + .with(tab.name, () => ) + .otherwise(() => null) + )} ); }; diff --git a/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx b/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx index dc3220faff..d2cba34e90 100644 --- a/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx +++ b/dashboard/src/main/home/database-dashboard/DatastoreFormContextProvider.tsx @@ -1,15 +1,21 @@ -import React, { createContext, useMemo, useState } from "react"; +import React, { createContext, useContext, 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 Loading from "components/Loading"; 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"; +import { useNeon } from "lib/hooks/useNeon"; + +import { Context } from "shared/Context"; + +import NeonIntegrationModal from "./shared/NeonIntegrationModal"; // todo(ianedwards): refactor button to use more predictable state export type UpdateDatastoreButtonProps = { @@ -20,6 +26,7 @@ export type UpdateDatastoreButtonProps = { type DatastoreFormContextType = { updateDatastoreButtonProps: UpdateDatastoreButtonProps; + projectId: number; }; const DatastoreFormContext = createContext( @@ -42,7 +49,12 @@ type DatastoreFormContextProviderProps = { const DatastoreFormContextProvider: React.FC< DatastoreFormContextProviderProps > = ({ children }) => { + const { currentProject } = useContext(Context); + const [updateDatastoreError, setUpdateDatastoreError] = useState(""); + const { getNeonIntegrations } = useNeon(); + const [showNeonIntegrationModal, setShowNeonIntegrationModal] = + useState(false); const { showIntercomWithMessage } = useIntercom(); @@ -85,6 +97,9 @@ const DatastoreFormContextProvider: React.FC< }, [isSubmitting, updateDatastoreError, errors]); const onSubmit = handleSubmit(async (data) => { + if (!currentProject) { + return; + } setUpdateDatastoreError(""); if (existingDatastores.some((db) => db.name === data.name)) { setUpdateDatastoreError( @@ -93,6 +108,15 @@ const DatastoreFormContextProvider: React.FC< return; } try { + if (data.config.type === "neon") { + const integrations = await getNeonIntegrations({ + projectId: currentProject.id, + }); + if (integrations.length === 0) { + setShowNeonIntegrationModal(true); + return; + } + } await createDatastore(data); history.push(`/datastores/${data.name}`); } catch (err) { @@ -107,10 +131,15 @@ const DatastoreFormContextProvider: React.FC< } }); + if (!currentProject) { + return ; + } + return ( @@ -118,6 +147,13 @@ const DatastoreFormContextProvider: React.FC<
{children}
+ {showNeonIntegrationModal && ( + { + setShowNeonIntegrationModal(false); + }} + /> + )}
); }; diff --git a/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx b/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx index 47e2ba755c..30c8783ef8 100644 --- a/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx +++ b/dashboard/src/main/home/database-dashboard/DatastoreProvisioningIndicator.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from "react"; import StatusBar from "components/porter/StatusBar"; +import { DATASTORE_TEMPLATE_NEON } from "./constants"; import { useDatastoreContext } from "./DatabaseContextProvider"; const DatastoreProvisioningIndicator: React.FC = () => { @@ -39,14 +40,18 @@ const DatastoreProvisioningIndicator: React.FC = () => { return { percentCompleted, title, titleDescriptor, isCreating }; }, [datastore]); + const subtitle = useMemo(() => { + return `${isCreating ? "Setup" : "Deletion"} can take up to ${ + datastore.template === DATASTORE_TEMPLATE_NEON ? 5 : 20 + } minutes. You can close this window and come back later.`; + }, [datastore]); + return ( ); diff --git a/dashboard/src/main/home/database-dashboard/constants.ts b/dashboard/src/main/home/database-dashboard/constants.ts index fc1aad5462..6aed92c2e8 100644 --- a/dashboard/src/main/home/database-dashboard/constants.ts +++ b/dashboard/src/main/home/database-dashboard/constants.ts @@ -13,6 +13,7 @@ import { DATASTORE_TYPE_ELASTICACHE, DATASTORE_TYPE_MANAGED_POSTGRES, DATASTORE_TYPE_MANAGED_REDIS, + DATASTORE_TYPE_NEON, DATASTORE_TYPE_RDS, type DatastoreEngine, type DatastoreTemplate, @@ -21,9 +22,15 @@ import { import awsRDS from "assets/amazon-rds.png"; import awsElastiCache from "assets/aws-elasticache.png"; 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 ConfigurationTab from "./tabs/ConfigurationTab"; +import ConnectTab from "./tabs/ConnectTab"; +import PublicDatastoreConnectTab from "./tabs/PublicDatastoreConnectTab"; +import SettingsTab from "./tabs/SettingsTab"; + export const DATASTORE_ENGINE_POSTGRES: DatastoreEngine = { name: "POSTGRES" as const, displayName: "PostgreSQL", @@ -103,7 +110,6 @@ export const DATASTORE_TEMPLATE_AWS_RDS: DatastoreTemplate = Object.freeze({ storageGigabytes: 2048, }, ], - formTitle: "Create an RDS PostgreSQL instance", creationStateProgression: [ DATASTORE_STATE_CREATING, DATASTORE_STATE_CONFIGURING_LOG_EXPORTS, @@ -117,6 +123,23 @@ export const DATASTORE_TEMPLATE_AWS_RDS: DatastoreTemplate = Object.freeze({ DATASTORE_STATE_DELETING_RECORD, DATASTORE_STATE_DELETED, ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: ConnectTab, + }, + { + name: "configuration", + displayName: "Configuration", + component: ConfigurationTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], }); export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({ name: "Amazon Aurora", @@ -145,7 +168,6 @@ export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({ storageGigabytes: 256, }, ], - formTitle: "Create an Aurora PostgreSQL instance", creationStateProgression: [ DATASTORE_STATE_CREATING, DATASTORE_STATE_AVAILABLE, @@ -155,6 +177,23 @@ export const DATASTORE_TEMPLATE_AWS_AURORA: DatastoreTemplate = Object.freeze({ DATASTORE_STATE_DELETING_RECORD, DATASTORE_STATE_DELETED, ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: ConnectTab, + }, + { + name: "configuration", + displayName: "Configuration", + component: ConfigurationTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], }); export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate = Object.freeze({ @@ -205,7 +244,6 @@ export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate = storageGigabytes: 0, }, ], - formTitle: "Create an ElastiCache Redis instance", creationStateProgression: [ DATASTORE_STATE_CREATING, DATASTORE_STATE_MODIFYING, @@ -218,6 +256,23 @@ export const DATASTORE_TEMPLATE_AWS_ELASTICACHE: DatastoreTemplate = DATASTORE_STATE_DELETING_RECORD, DATASTORE_STATE_DELETED, ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: ConnectTab, + }, + { + name: "configuration", + displayName: "Configuration", + component: ConfigurationTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], }); export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate = Object.freeze({ @@ -246,7 +301,6 @@ export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate = storageGigabytes: 2, }, ], - formTitle: "Create an ElastiCache Memcached instance", creationStateProgression: [ DATASTORE_STATE_CREATING, DATASTORE_STATE_AVAILABLE, @@ -256,6 +310,23 @@ export const DATASTORE_TEMPLATE_MANAGED_REDIS: DatastoreTemplate = DATASTORE_STATE_DELETING_RECORD, DATASTORE_STATE_DELETED, ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: ConnectTab, + }, + { + name: "configuration", + displayName: "Configuration", + component: ConfigurationTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], }); export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate = Object.freeze({ @@ -284,7 +355,6 @@ export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate = storageGigabytes: 2, }, ], - formTitle: "Create a managed PostgreSQL instance", creationStateProgression: [ DATASTORE_STATE_CREATING, DATASTORE_STATE_AVAILABLE, @@ -294,12 +364,65 @@ export const DATASTORE_TEMPLATE_MANAGED_POSTGRES: DatastoreTemplate = DATASTORE_STATE_DELETING_RECORD, DATASTORE_STATE_DELETED, ], + tabs: [ + { + name: "connectivity", + displayName: "Connectivity", + component: ConnectTab, + }, + { + name: "configuration", + displayName: "Configuration", + component: ConfigurationTab, + }, + { + name: "settings", + displayName: "Settings", + component: SettingsTab, + }, + ], }); +export const DATASTORE_TEMPLATE_NEON: DatastoreTemplate = Object.freeze({ + name: "Neon", + displayName: "Neon", + highLevelType: DATASTORE_ENGINE_POSTGRES, + type: DATASTORE_TYPE_NEON, + engine: DATASTORE_ENGINE_POSTGRES, + supportedEngineVersions: [], + icon: neon as string, + description: + "A fully managed serverless Postgres. Neon separates storage and compute to offer autoscaling, branching, and bottomless storage.", + 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, DATASTORE_TEMPLATE_AWS_ELASTICACHE, DATASTORE_TEMPLATE_MANAGED_POSTGRES, DATASTORE_TEMPLATE_MANAGED_REDIS, + DATASTORE_TEMPLATE_NEON, ]; diff --git a/dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx b/dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx index cac2975d0d..3fe77804f7 100644 --- a/dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx +++ b/dashboard/src/main/home/database-dashboard/forms/CreateDatastore.tsx @@ -1,12 +1,26 @@ -import React from "react"; +import React, { useContext } from "react"; +import { match } from "ts-pattern"; + +import Loading from "components/Loading"; + +import { Context } from "shared/Context"; import DatastoreFormContextProvider from "../DatastoreFormContextProvider"; import DatastoreForm from "./DatastoreForm"; +import SandboxDatastoreForm from "./SandboxDatastoreForm"; const CreateDatastore: React.FC = () => { + const { currentProject } = useContext(Context); + + if (!currentProject) { + return ; + } return ( - + {match(currentProject) + .with({ sandbox_enabled: true }, () => ) + .with({ sandbox_enabled: false }, () => ) + .exhaustive()} ); }; diff --git a/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx b/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx index c13e60cbd9..9c7df6ca99 100644 --- a/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx +++ b/dashboard/src/main/home/database-dashboard/forms/DatastoreForm.tsx @@ -378,7 +378,7 @@ const DatastoreForm: React.FC = () => { database_name: watchDbName, } } - engine={template.engine} + template={template} /> )} diff --git a/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx b/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx new file mode 100644 index 0000000000..70590cf62d --- /dev/null +++ b/dashboard/src/main/home/database-dashboard/forms/SandboxDatastoreForm.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import styled from "styled-components"; +import { match } from "ts-pattern"; + +import Back from "components/porter/Back"; +import BlockSelect, { + type BlockSelectOption, +} from "components/porter/BlockSelect"; +import Button from "components/porter/Button"; +import { ControlledInput } from "components/porter/ControlledInput"; +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 { type DatastoreTemplate, type DbFormData } from "lib/databases/types"; +import { useClusterList } from "lib/hooks/useCluster"; + +import { valueExists } from "shared/util"; +import database from "assets/database.svg"; + +import { + DATASTORE_ENGINE_POSTGRES, + DATASTORE_ENGINE_REDIS, + DATASTORE_TEMPLATE_NEON, + SUPPORTED_DATASTORE_TEMPLATES, +} from "../constants"; +import { useDatastoreFormContext } from "../DatastoreFormContextProvider"; + +const SandboxDatastoreForm: React.FC = () => { + const [currentStep, setCurrentStep] = useState(0); + const [template, setTemplate] = useState( + undefined + ); + + const { clusters } = useClusterList(); + + const { + setValue, + formState: { errors }, + register, + watch, + } = useFormContext(); + const watchClusterId = watch("clusterId", 0); + const watchEngine = watch("engine", "UNKNOWN"); + + const { updateDatastoreButtonProps } = useDatastoreFormContext(); + + const availableEngines: BlockSelectOption[] = useMemo(() => { + return [ + DATASTORE_ENGINE_POSTGRES, + { + ...DATASTORE_ENGINE_REDIS, + disabledOpts: { + tooltipText: "Coming soon!", + }, + }, + ]; + }, [watchClusterId]); + + const availableHostTypes: BlockSelectOption[] = useMemo(() => { + const options = [DATASTORE_TEMPLATE_NEON].filter( + (t) => t.highLevelType.name === watchEngine + ); + return options; + }, [watchEngine]); + + useEffect(() => { + if (clusters.length > 0) { + setValue("clusterId", clusters[0].id); + } + }, [JSON.stringify(clusters)]); + + return ( +
+ + + } + title={"Create a new datastore"} + capitalize={false} + disableLineBreak + /> + + + Datastore type + + ( + e.name === value + )} + setOption={(opt) => { + onChange(opt.name); + setValue("workloadType", "unspecified"); + setTemplate(undefined); + setCurrentStep(1); + }} + /> + )} + /> + , + <> + Datastore name + {watchEngine !== "UNKNOWN" && ( + <> + + + Lowercase letters, numbers, and "-" only. + + + { + setValue("name", e.target.value); + setCurrentStep(Math.max(2, currentStep)); + }} + /> + {clusters.length > 1 && ( + <> + + + 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("workloadType", "unspecified"); + setCurrentStep(2); + }} + label={"Cluster"} + /> + + )} + + )} + , + <> + Hosting option + {currentStep >= 2 && ( + <> + + 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_NEON.name, + }, + () => { + setValue("config.type", "neon"); + } + ); + setCurrentStep(4); + }} + /> + + )} + , + <> + Create datastore instance + + + , + ].filter(valueExists)} + currentStep={currentStep} + /> + +
+ ); +}; + +export default SandboxDatastoreForm; + +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); + } + } +`; diff --git a/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx b/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx index efcf2e68eb..750da78703 100644 --- a/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx +++ b/dashboard/src/main/home/database-dashboard/shared/ConnectionInfo.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { useMemo } from "react"; import styled from "styled-components"; +import { match } from "ts-pattern"; import ClickToCopy from "components/porter/ClickToCopy"; import Container from "components/porter/Container"; @@ -7,19 +8,62 @@ import Fieldset from "components/porter/Fieldset"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; import { + DATASTORE_TYPE_ELASTICACHE, + DATASTORE_TYPE_MANAGED_POSTGRES, + DATASTORE_TYPE_MANAGED_REDIS, + DATASTORE_TYPE_NEON, + DATASTORE_TYPE_RDS, type DatastoreConnectionInfo, - type DatastoreEngine, + type DatastoreTemplate, } from "lib/databases/types"; -import { DATASTORE_ENGINE_REDIS } from "../constants"; +import { + DATASTORE_ENGINE_POSTGRES, + DATASTORE_ENGINE_REDIS, +} from "../constants"; type Props = { connectionInfo: DatastoreConnectionInfo; - engine: DatastoreEngine; + template: DatastoreTemplate; }; -const ConnectionInfo: React.FC = ({ connectionInfo, engine }) => { +const ConnectionInfo: React.FC = ({ connectionInfo, template }) => { const [isPasswordHidden, setIsPasswordHidden] = React.useState(true); + const connectionString = useMemo(() => { + return match(template) + .returnType() + .with({ highLevelType: DATASTORE_ENGINE_REDIS }, () => + match(template) + .with( + { type: DATASTORE_TYPE_ELASTICACHE }, + () => + `rediss://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0?ssl_cert_reqs=CERT_REQUIRED` + ) + .with( + { type: DATASTORE_TYPE_MANAGED_REDIS }, + () => + `redis://:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/0` + ) + .otherwise(() => "") + ) + .with({ highLevelType: DATASTORE_ENGINE_POSTGRES }, () => + match(template) + .with( + { type: DATASTORE_TYPE_RDS }, + { type: DATASTORE_TYPE_NEON }, + () => + `postgres://${connectionInfo.username}:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/${connectionInfo.database_name}?sslmode=require` + ) + .with( + { type: DATASTORE_TYPE_MANAGED_POSTGRES }, + () => + `postgres://${connectionInfo.username}:${connectionInfo.password}@${connectionInfo.host}:${connectionInfo.port}/${connectionInfo.database_name}` + ) + .otherwise(() => "") + ) + .otherwise(() => ""); + }, [template]); + return (
@@ -42,7 +86,7 @@ const ConnectionInfo: React.FC = ({ connectionInfo, engine }) => { - {engine === DATASTORE_ENGINE_REDIS ? ( + {template.highLevelType === DATASTORE_ENGINE_REDIS ? ( )} + {connectionString !== "" && ( + + + + + )}
Auth token @@ -135,6 +179,40 @@ const ConnectionInfo: React.FC = ({ connectionInfo, engine }) => {
+ Connection string + + {isPasswordHidden ? ( + + {connectionString} + + { + setIsPasswordHidden(false); + }} + > + Reveal + + + ) : ( + + {connectionString} + + { + setIsPasswordHidden(true); + }} + > + Hide + + + )} +
diff --git a/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx b/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx new file mode 100644 index 0000000000..3c0118efb8 --- /dev/null +++ b/dashboard/src/main/home/database-dashboard/shared/NeonIntegrationModal.tsx @@ -0,0 +1,60 @@ +import React, { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import Link from "components/porter/Link"; +import Modal from "components/porter/Modal"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; +import { useAuthWindow } from "lib/hooks/useAuthWindow"; +import { useNeon } from "lib/hooks/useNeon"; + +import { useDatastoreFormContext } from "../DatastoreFormContextProvider"; + +type Props = { + onClose: () => void; +}; + +const NeonIntegrationModal: React.FC = ({ onClose }) => { + const { projectId } = useDatastoreFormContext(); + const { getNeonIntegrations } = useNeon(); + const { openAuthWindow } = useAuthWindow({ + authUrl: `/api/projects/${projectId}/oauth/neon`, + }); + + const neonIntegrationsResp = useQuery( + ["getNeonIntegrations", projectId], + async () => { + const integrations = await getNeonIntegrations({ + projectId, + }); + return integrations; + }, + { + enabled: !!projectId, + refetchInterval: 1000, + } + ); + useEffect(() => { + if ( + neonIntegrationsResp.isSuccess && + neonIntegrationsResp.data.length > 0 + ) { + onClose(); + } + }, [neonIntegrationsResp]); + + return ( + + Integrate Neon + + + To continue, you must authenticate with Neon.{" "} + + Authorize Porter to create Neon datastores on your behalf + + + + ); +}; + +export default NeonIntegrationModal; diff --git a/dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx index 81261cf853..2e223518e9 100644 --- a/dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx +++ b/dashboard/src/main/home/database-dashboard/tabs/ConnectTab.tsx @@ -59,7 +59,7 @@ const ConnectTab: React.FC = () => { @@ -124,14 +124,14 @@ const ConnectTab: React.FC = () => { export default ConnectTab; -const ConnectTabContainer = styled.div` +export const ConnectTabContainer = styled.div` width: 100%; height: 100%; display: flex; flex-direction: row; `; -const IdContainer = styled.div` +export const IdContainer = styled.div` width: fit-content; background: #000000; border-radius: 5px; diff --git a/dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx new file mode 100644 index 0000000000..2c301b5b43 --- /dev/null +++ b/dashboard/src/main/home/database-dashboard/tabs/PublicDatastoreConnectTab.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import styled from "styled-components"; + +import Banner from "components/porter/Banner"; +import Container from "components/porter/Container"; +import ShowIntercomButton from "components/porter/ShowIntercomButton"; +import Spacer from "components/porter/Spacer"; +import Text from "components/porter/Text"; + +import { useDatastoreContext } from "../DatabaseContextProvider"; +import ConnectionInfo from "../shared/ConnectionInfo"; + +// use this for external datastores that are publicly exposed like neon, upstash, etc. +const PublicDatastoreConnectTab: React.FC = () => { + const { datastore } = useDatastoreContext(); + + if (datastore.credential.host === "") { + return ( + + + Talk to support + + + } + > + Error reaching your datastore for credentials. Please contact support. + + + ); + } + return ( + +
+ + Connection info + + + + + + The datastore client of your application should use these credentials + to create a connection.{" "} + +
+
+ ); +}; + +export default PublicDatastoreConnectTab; + +const ConnectTabContainer = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: row; +`; diff --git a/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx b/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx index 3ecbf390ab..ba10549d46 100644 --- a/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx +++ b/dashboard/src/main/home/database-dashboard/tabs/SettingsTab.tsx @@ -46,7 +46,6 @@ const SettingsTab: React.FC = () => { {showDeleteDatastoreModal && ( { setShowDeleteDatastoreModal(false); }} @@ -62,16 +61,16 @@ const SettingsTab: React.FC = () => { export default SettingsTab; type DeleteDatastoreModalProps = { - datastoreName: string; onSubmit: () => Promise; onClose: () => void; }; const DeleteDatastoreModal: React.FC = ({ - datastoreName, onSubmit, onClose, }) => { + const { datastore } = useDatastoreContext(); + const [inputtedDatastoreName, setInputtedDatastoreName] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -114,33 +113,36 @@ const DeleteDatastoreModal: React.FC = ({ return ( - Delete {datastoreName}? - - - - Attention: - - - - Destruction of resources sometimes results in dangling resources. To - ensure that everything has been properly destroyed, please visit your - cloud provider's console. - - - - Deletion instructions - + Delete {datastore.name}? + {datastore.cloud_provider_credential_identifier !== "" && ( + <> + + Attention: + + + + Destruction of resources sometimes results in dangling resources. To + ensure that everything has been properly destroyed, please visit + your cloud provider's console. + + + + Deletion instructions + + + + )} To confirm, enter the datastore name below. This action is irreversible. = ({