From 539c2bbace61f9eef5b7fefd4a53d5c62cdf28d8 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 8 Dec 2023 13:41:35 +0800 Subject: [PATCH] feat/rr-functionality (#19466) * Implement read, create, delete replicas * Long poll replica statuses if any one of them is coming up * Support querying replicas in SQL editor * Add optimistic rendering for setting up and removing replicas * Small style fix for database selector in SQL editor * Small fix * Add Alerts around PITR and PG upgrades RE read replicas * Small fixes after testing flag off * Add UI guards to check that project has PITR before deploying replica * Fix * Address feedback * Fix * Update replicas RQ to enable based on flag --- .../Database/Backups/PITR/PITRSelection.tsx | 42 ++- .../Database/Backups/PITR/PITRStatus.tsx | 9 +- .../interfaces/SQLEditor/SQLEditor.tsx | 21 +- .../SQLEditor/UtilityPanel/UtilityActions.tsx | 23 +- .../DatabaseSettings/DatabaseSettings.tsx | 66 +++-- .../Settings/Database/SSLConfiguration.tsx | 72 ++--- .../Infrastructure/InfrastructureActivity.tsx | 7 +- .../DeployNewReplicaPanel.tsx | 108 ++++++- .../DropReplicaConfirmationModal.tsx | 76 +++++ .../InstanceConfiguration.constants.ts | 89 +----- .../InstanceConfiguration.tsx | 267 ++++++++++-------- .../InstanceConfiguration.utils.ts | 61 ++-- .../InstanceNode.tsx | 59 ++-- .../InfrastructureConfiguration/MapView.tsx | 91 +++--- .../ResizeReplicaPanel.tsx | 7 +- .../Infrastructure/InfrastructureInfo.tsx | 16 +- .../LayoutHeader/BreadcrumbsView.tsx | 35 +-- .../studio/components/ui/DatabaseSelector.tsx | 72 +++-- apps/studio/data/api.d.ts | 91 ++++++ apps/studio/data/read-replicas/keys.ts | 5 + .../read-replicas/replica-remove-mutation.ts | 66 +++++ .../read-replicas/replica-setup-mutation.ts | 96 +++++++ .../data/read-replicas/replicas-query.ts | 39 +++ .../read-replicas/replicas-status-query.ts | 42 +++ .../data/read-replicas/replicas.utils.ts | 7 + apps/studio/state/sql-editor.ts | 5 + 26 files changed, 1051 insertions(+), 421 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropReplicaConfirmationModal.tsx create mode 100644 apps/studio/data/read-replicas/keys.ts create mode 100644 apps/studio/data/read-replicas/replica-remove-mutation.ts create mode 100644 apps/studio/data/read-replicas/replica-setup-mutation.ts create mode 100644 apps/studio/data/read-replicas/replicas-query.ts create mode 100644 apps/studio/data/read-replicas/replicas-status-query.ts create mode 100644 apps/studio/data/read-replicas/replicas.utils.ts diff --git a/apps/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/apps/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx index b3db18a8ed6..790bddce705 100644 --- a/apps/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx +++ b/apps/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx @@ -6,7 +6,19 @@ import dayjs from 'dayjs' import { useRouter } from 'next/router' import { useState } from 'react' import DatePicker from 'react-datepicker' -import { Alert, Button, IconChevronLeft, IconChevronRight, IconHelpCircle, Modal } from 'ui' +import { + Alert, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconAlertTriangle, + IconChevronLeft, + IconChevronRight, + IconExternalLink, + IconHelpCircle, + Modal, +} from 'ui' import { FormHeader, FormPanel } from 'components/ui/Forms' import InformationBox from 'components/ui/InformationBox' @@ -25,6 +37,8 @@ import { import PITRStatus from './PITRStatus' import TimeInput from './TimeInput' import TimezoneSelection from './TimezoneSelection' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import Link from 'next/link' const PITRSelection = () => { const router = useRouter() @@ -32,10 +46,13 @@ const PITRSelection = () => { const queryClient = useQueryClient() const { data: backups } = useBackupsQuery({ projectRef: ref }) + const { data: databases } = useReadReplicasQuery({ projectRef: ref }) const [showConfiguration, setShowConfiguration] = useState(false) const [showConfirmation, setShowConfirmation] = useState(false) const [selectedTimezone, setSelectedTimezone] = useState(getClientTimezone()) + const hasReadReplicas = (databases ?? []).length > 1 + const { mutate: restoreFromPitr, isLoading: isRestoring, @@ -126,6 +143,29 @@ const PITRSelection = () => { ) : ( <> + {hasReadReplicas && ( + + + + Unable to restore from PITR as project has read replicas enabled + + + You will need to remove all read replicas first from your project's infrastructure + settings prior to starting a PITR restore. + +
+ {/* [Joshen] Ideally we have some links to a docs to explain why so */} + {/* */} + +
+
+ )} {!showConfiguration ? ( { const { ref } = useParams() const { data: backups } = useBackupsQuery({ projectRef: ref }) + const { data: databases } = useReadReplicasQuery({ projectRef: ref }) + + const hasReadReplicas = (databases ?? []).length > 1 const { earliestPhysicalBackupDateUnix, latestPhysicalBackupDateUnix } = backups?.physicalBackupData ?? {} @@ -57,7 +61,10 @@ const PITRStatus = ({ - diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index ad1fe27149e..e5d3b0e688d 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -66,6 +66,8 @@ import { getDiffTypeDropdownLabel, } from './SQLEditor.utils' import UtilityPanel from './UtilityPanel/UtilityPanel' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import toast from 'react-hot-toast' // Load the monaco editor client-side only (does not behave well server-side) const MonacoEditor = dynamic(() => import('./MonacoEditor'), { ssr: false }) @@ -119,6 +121,9 @@ const SQLEditor = () => { const supabaseAIEnabled = useFlag('sqlEditorSupabaseAI') const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) + const { data: databases, isSuccess: isSuccessReadReplicas } = useReadReplicasQuery({ + projectRef: ref, + }) // Customers on HIPAA plans should not have access to Supabase AI const hasHipaaAddon = subscriptionHasHipaaAddon(subscription) @@ -150,6 +155,13 @@ const SQLEditor = () => { setIsFirstRender(false) }, []) + useEffect(() => { + if (isSuccessReadReplicas) { + const primaryDatabase = databases.find((db) => db.identifier === ref) + snap.setSelectedDatabaseId(primaryDatabase?.identifier) + } + }, [isSuccessReadReplicas]) + const { data, refetch: refetchEntityDefinitions } = useEntityDefinitionsQuery( { projectRef: selectedProject?.ref, @@ -310,9 +322,16 @@ const SQLEditor = () => { setLineHighlights([]) } + const connectionString = databases?.find( + (db) => db.identifier === snap.selectedDatabaseId + )?.connectionString + if (!connectionString) { + return toast.error('Unable to run query: Connection string is missing') + } + execute({ projectRef: project.ref, - connectionString: project.connectionString, + connectionString: connectionString, sql: wrapWithRoleImpersonation(sql, { projectRef: project.ref, role: getImpersonatedRole(), diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index d4c6130beb3..2deb45d696c 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -9,6 +9,7 @@ import { useFlag } from 'hooks' import { useState } from 'react' import FavoriteButton from './FavoriteButton' import SavingIndicator from './SavingIndicator' +import { useSqlEditorStateSnapshot } from 'state/sql-editor' export type UtilityActionsProps = { id: string @@ -28,9 +29,9 @@ const UtilityActions = ({ executeQuery, }: UtilityActionsProps) => { const os = detectOS() + const snap = useSqlEditorStateSnapshot() const readReplicasEnabled = useFlag('readReplicas') const roleImpersonationEnabledFlag = useFlag('roleImpersonation') - const [selectedDatabaseId, setSelectedDatabaseId] = useState('1') return ( <> @@ -67,16 +68,20 @@ const UtilityActions = ({
- {readReplicasEnabled && ( - - )} -
+ {readReplicasEnabled && ( + + )} + {roleImpersonationEnabledFlag && ( - + )}
))} - {isError && ( - - )} + {isError && } {isSuccess && ( <> { layout="horizontal" disabled readOnly - value={'[The password you provided when you created this project]'} + value={ + selectedDatabaseId !== projectRef + ? '[The password for your primary database]' + : '[The password you provided when you created this project]' + } label="Password" /> @@ -231,8 +255,6 @@ const DatabaseSettings = () => { - -
{
+ +
{ {(isLoading || isSubmitting) && ( )} - - - - - {(!canUpdateSSLEnforcement || !hasAccessToSSLEnforcement) && ( - - - -
- - {!canUpdateSSLEnforcement - ? 'You need additional permissions to update SSL enforcement for your project' - : !hasAccessToSSLEnforcement - ? 'Your project does not have access to SSL enforcement' - : ''} - -
-
-
- )} -
+ {isSuccess && ( + + + + + {(!canUpdateSSLEnforcement || !hasAccessToSSLEnforcement) && ( + + + +
+ + {!canUpdateSSLEnforcement + ? 'You need additional permissions to update SSL enforcement for your project' + : !hasAccessToSSLEnforcement + ? 'Your project does not have access to SSL enforcement' + : ''} + +
+
+
+ )} +
+ )} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index 9521c9ca9a4..276f0270476 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -35,8 +35,9 @@ const InfrastructureActivity = () => { const { ref: projectRef } = useParams() const organization = useSelectedOrganization() const [dateRange, setDateRange] = useState() - const [selectedDatabaseId, setSelectedDatabaseId] = useState('1') + // [Joshen] Not for first iteration of read replicas + const [selectedDatabaseId, setSelectedDatabaseId] = useState('1') const readReplicasEnabled = useFlag('readReplicas') const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({ @@ -187,12 +188,12 @@ const InfrastructureActivity = () => {
- {readReplicasEnabled && ( + {/* {readReplicasEnabled && ( - )} + )} */} {!isLoadingSubscription && ( <> void onClose: () => void } const DeployNewReplicaPanel = ({ visible, selectedDefaultRegion, + onSuccess, onClose, }: DeployNewReplicaPanelProps) => { const { ref: projectRef } = useParams() + const org = useSelectedOrganization() + const { data } = useReadReplicasQuery({ projectRef }) const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef }) + const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug }) + const { mutate: setUpReplica, isLoading: isSettingUp } = useReadReplicaSetUpMutation({ + onSuccess: () => { + const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === selectedRegion)?.name + toast.success(`Spinning up new replica in ${region ?? ' Unknown'}...`) + onSuccess() + onClose() + }, + }) + + const isFreePlan = subscription?.plan.id === 'free' + const currentComputeAddon = addons?.selected_addons.find( + (addon) => addon.type === 'compute_instance' + ) + const currentPitrAddon = addons?.selected_addons.find((addon) => addon.type === 'pitr') + const canDeployReplica = + !isFreePlan && currentComputeAddon !== undefined && currentPitrAddon !== undefined + const computeAddons = addons?.available_addons.find((addon) => addon.type === 'compute_instance')?.variants ?? [] // Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet - const [defaultRegion] = - Object.entries(AWS_REGIONS).find(([key, name]) => name === AWS_REGIONS_DEFAULT) ?? [] - const defaultCompute = computeAddons.find((option) => option.name === 'Small')?.identifier + const [defaultRegion] = Object.entries(AWS_REGIONS).find( + ([_, name]) => name === AWS_REGIONS_DEFAULT + ) ?? ['ap-southeast-1'] + // Will be following the primary's instance size for the time being + const defaultCompute = + addons?.selected_addons.find((addon) => addon.type === 'compute_instance')?.variant + .identifier ?? 'ci_small' - const [selectedRegion, setSelectedRegion] = useState(defaultRegion) + const [selectedRegion, setSelectedRegion] = useState(defaultRegion) const [selectedCompute, setSelectedCompute] = useState(defaultCompute) const selectedComputeMeta = computeAddons.find((addon) => addon.identifier === selectedCompute) const onSubmit = async () => { - console.log('Deploy', { selectedRegion, selectedCompute }) - onClose() + const regionKey = AWS_REGIONS_VALUES[selectedRegion] + if (!projectRef) return console.error('Project is required') + if (!regionKey) return toast.error('Unable to deploy replica: Unsupported region selected') + + const primary = data?.find((db) => db.identifier === projectRef) + setUpReplica({ projectRef, region: regionKey as Region, size: primary?.size ?? 't4g.small' }) } useEffect(() => { @@ -52,15 +96,57 @@ const DeployNewReplicaPanel = ({ return ( onSubmit()} + confirmText="Deploy replica" + disabled={!canDeployReplica} header="Deploy a new read replica" > + {!canDeployReplica && ( + + + + Point in time recovery is required to deploy replicas + + {isFreePlan ? ( + + To enable PITR, you may first upgrade your organization's plan to at least Pro, then + purchase the PITR add on for your project via the{' '} + + project settings + + . + + ) : ( + + Enable the add-on in your project's settings first before deploying read replicas. + + )} + + + + + )} {computeAddons.map((option) => ( @@ -98,9 +186,9 @@ const DeployNewReplicaPanel = ({ ))} -

+ {/*

Show some preview info on cost for deploying this replica here -

+

*/}
) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropReplicaConfirmationModal.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropReplicaConfirmationModal.tsx new file mode 100644 index 00000000000..9a9dcb09ccf --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DropReplicaConfirmationModal.tsx @@ -0,0 +1,76 @@ +import { useParams } from 'common' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { useReadReplicaRemoveMutation } from 'data/read-replicas/replica-remove-mutation' +import { Database } from 'data/read-replicas/replicas-query' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' +import toast from 'react-hot-toast' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + IconAlertTriangle, + Modal, +} from 'ui' + +interface DropReplicaConfirmationModalProps { + selectedReplica?: Database + onSuccess: () => void + onCancel: () => void +} + +const DropReplicaConfirmationModal = ({ + selectedReplica, + onSuccess, + onCancel, +}: DropReplicaConfirmationModalProps) => { + const { ref: projectRef } = useParams() + const formattedId = formatDatabaseID(selectedReplica?.identifier ?? '') + const { mutateAsync: removeReadReplica } = useReadReplicaRemoveMutation({ + onSuccess: () => { + toast.success(`Successfully removed read replica (ID: ${formattedId})`) + onSuccess() + onCancel() + }, + }) + + const onConfirmRemove = async () => { + if (!projectRef) return console.error('Project is required') + if (selectedReplica === undefined) return toast.error('No replica selected') + + await removeReadReplica({ projectRef, identifier: selectedReplica.identifier }) + } + + return ( + onCancel()} + onSelectConfirm={() => onConfirmRemove()} + > + + + + This action cannot be undone + + You may still deploy a new replica in this region thereafter + + +
+

Before deleting this replica, consider:

+
    +
  • + Network traffic from this region may slow down, especially if you have no other + replicas in this region +
  • +
+
+
+
+ ) +} + +export default DropReplicaConfirmationModal diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts index b784243d424..024ee86c02e 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts @@ -1,15 +1,5 @@ import { AWS_REGIONS, AWS_REGIONS_KEYS } from 'lib/constants' -export interface DatabaseConfiguration { - id: number - type: 'PRIMARY' | 'READ_REPLICA' - cloud_provider: 'AWS' | 'FLY' - region: string - size: string - status: string - inserted_at: string -} - export interface Region { key: AWS_REGIONS_KEYS name: string @@ -18,85 +8,10 @@ export interface Region { } // ReactFlow is scaling everything by the factor of 2 -export const NODE_WIDTH = 560 +export const NODE_WIDTH = 660 export const NODE_ROW_HEIGHT = 50 export const NODE_SEP = 20 -export const MOCK_DATABASES: DatabaseConfiguration[] = [ - { - id: 1, - type: 'PRIMARY', - cloud_provider: 'AWS', - region: 'ap-southeast-1b', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 2, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'ap-northeast-1', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 3, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'ap-southeast-1b', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 4, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'ap-northeast-2', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 5, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'eu-central-1', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 6, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'eu-central-1', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 7, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'ap-southeast-1', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, - { - id: 8, - type: 'READ_REPLICA', - cloud_provider: 'AWS', - region: 'ap-southeast-1', - size: 't4g.micro', - status: 'ACTIVE_HEALTHY', - inserted_at: '2023-11-01 06:47:46.837002', - }, -] - // [Joshen] Coordinates from https://github.com/jsonmaur/aws-regions/issues/11 const AWS_REGIONS_COORDINATES: { [key: string]: [number, number] } = { SOUTHEAST_ASIA: [103.8, 1.37], @@ -113,7 +28,7 @@ const AWS_REGIONS_COORDINATES: { [key: string]: [number, number] } = { SOUTH_AMERICA: [-46.38, -23.34], } -const AWS_REGIONS_VALUES: { [key: string]: string } = { +export const AWS_REGIONS_VALUES: { [key: string]: string } = { SOUTHEAST_ASIA: 'ap-southeast-1', NORTHEAST_ASIA: 'ap-northeast-1', NORTHEAST_ASIA_2: 'ap-northeast-2', diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 7c3b21a30c6..539f54aeaa1 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -1,27 +1,21 @@ import { useParams } from 'common' -import { partition } from 'lodash' -import { Globe2, Network } from 'lucide-react' +import { isEqual, partition } from 'lodash' +import { Globe2, Loader2, Network } from 'lucide-react' import { useTheme } from 'next-themes' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, Edge, ReactFlowProvider, useReactFlow } from 'reactflow' import 'reactflow/dist/style.css' -import { - AlertDescription_Shadcn_, - AlertTitle_Shadcn_, - Alert_Shadcn_, - Button, - IconAlertTriangle, - Modal, -} from 'ui' +import { Button } from 'ui' -import ConfirmationModal from 'components/ui/ConfirmationModal' +import AlertError from 'components/ui/AlertError' +import { Database, useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { useReadReplicasStatusesQuery } from 'data/read-replicas/replicas-status-query' import { AWS_REGIONS_KEYS } from 'lib/constants' import DeployNewReplicaPanel from './DeployNewReplicaPanel' -import { DatabaseConfiguration, MOCK_DATABASES } from './InstanceConfiguration.constants' +import DropReplicaConfirmationModal from './DropReplicaConfirmationModal' import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils' import { PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' -import ResizeReplicaPanel from './ResizeReplicaPanel' // [Joshen] Just FYI, UI assumes single provider for primary + replicas // [Joshen] Idea to visualize grouping based on region: https://reactflow.dev/examples/layout/sub-flows @@ -31,30 +25,67 @@ const InstanceConfigurationUI = () => { const reactFlow = useReactFlow() const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() + const numComingUp = useRef() const [view, setView] = useState<'flow' | 'map'>('flow') const [showNewReplicaPanel, setShowNewReplicaPanel] = useState(false) + const [refetchInterval, setRefetchInterval] = useState(10000) const [newReplicaRegion, setNewReplicaRegion] = useState() - const [selectedReplicaToResize, setSelectedReplicaToResize] = useState() - const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState() - const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState() + const [selectedReplicaToResize, setSelectedReplicaToResize] = useState() + const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState() + const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState() + + const { data, error, refetch, isLoading, isError, isSuccess } = useReadReplicasQuery({ + projectRef, + }) + const [[primary], replicas] = useMemo( + () => partition(data ?? [], (db) => db.identifier === projectRef), + [data, projectRef] + ) + + useReadReplicasStatusesQuery( + { projectRef }, + { + refetchInterval: refetchInterval as any, + refetchOnWindowFocus: false, + onSuccess: (data) => { + const comingUpReplicas = data.filter((db) => db.status === 'COMING_UP') + const hasTransientStatus = comingUpReplicas.length > 0 + + // If any replica's status has changed, refetch databases + if (numComingUp.current !== comingUpReplicas.length) { + numComingUp.current = comingUpReplicas.length + refetch() + } + + // If all replicas are active healthy, stop fetching statuses + if (!hasTransientStatus) { + setRefetchInterval(false) + } + }, + } + ) const backgroundPatternColor = resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)' - const [[primary], replicas] = partition(MOCK_DATABASES, (database) => database.type === 'PRIMARY') - - const nodes = generateNodes(MOCK_DATABASES, { - onSelectRestartReplica: setSelectedReplicaToRestart, - onSelectResizeReplica: setSelectedReplicaToResize, - onSelectDropReplica: setSelectedReplicaToDrop, - }) + const nodes = useMemo( + () => + isSuccess + ? generateNodes(primary, replicas, { + onSelectRestartReplica: setSelectedReplicaToRestart, + onSelectResizeReplica: setSelectedReplicaToResize, + onSelectDropReplica: setSelectedReplicaToDrop, + }) + : [], + [isSuccess, primary, replicas] + ) const edges: Edge[] = replicas.map((database) => { return { - id: `${primary.id}-${database.id}`, - source: `database-${primary.id}`, - target: `database-${database.id}`, + id: `${primary.identifier}-${database.identifier}`, + source: primary.identifier, + target: database.identifier, type: 'smoothstep', animated: true, } @@ -73,115 +104,113 @@ const InstanceConfigurationUI = () => { console.log('Restart replica', selectedReplicaToRestart) } + // [Joshen] Just FYI this block is oddly triggering whenever we refocus on the viewport + // even if I change the dependency array to just data. Not blocker, just an area to optimize + useEffect(() => { + if (replicas.length > 0) { + const graph = getDagreGraphLayout(nodes, edges) + const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges) + reactFlow.setNodes(updatedNodes) + reactFlow.setEdges(graph.edges) + } + }, [replicas]) + return ( <> -
-
- -
-
-
- {view === 'flow' ? ( - { - const graph = getDagreGraphLayout(nodes, edges) - const xxx = addRegionNodes(graph.nodes, graph.edges) - reactFlow.setNodes(xxx.nodes) - reactFlow.setEdges(graph.edges) - }} - proOptions={{ hideAttribution: true }} - > - - - ) : ( - { - setNewReplicaRegion(region) - setShowNewReplicaPanel(true) - }} - onSelectRestartReplica={setSelectedReplicaToRestart} - onSelectResizeReplica={setSelectedReplicaToResize} - onSelectDropReplica={setSelectedReplicaToDrop} - /> +
+ {isLoading && } + {isError && } + {isSuccess && ( + <> +
+ +
+
+
+ {view === 'flow' ? ( + { + const graph = getDagreGraphLayout(nodes, edges) + const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges) + reactFlow.setNodes(updatedNodes) + reactFlow.setEdges(graph.edges) + }} + proOptions={{ hideAttribution: true }} + > + + + ) : ( + { + setNewReplicaRegion(region) + setShowNewReplicaPanel(true) + }} + onSelectRestartReplica={setSelectedReplicaToRestart} + onSelectResizeReplica={setSelectedReplicaToResize} + onSelectDropReplica={setSelectedReplicaToDrop} + /> + )} + )}
setRefetchInterval(10000)} onClose={() => { setNewReplicaRegion(undefined) setShowNewReplicaPanel(false) }} /> - setRefetchInterval(10000)} + onCancel={() => setSelectedReplicaToDrop(undefined)} + /> + + {/* setSelectedReplicaToResize(undefined)} - /> + /> */} - setSelectedReplicaToDrop(undefined)} - onSelectConfirm={() => onConfirmDropReplica()} - > - - - - This action cannot be undone - - You may still deploy a new replica in this region thereafter - - -
-

Before deleting this replica, consider:

-
    -
  • - Network traffic from this region may slow down, especially if you have no other - replicas in this region -
  • -
-
-
-
- - { Are you sure you want to restart this replica (ID: {selectedReplicaToRestart?.id}) now?{' '}

-
+ */} ) } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.utils.ts index c470ef8063a..0d9a01277f5 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.utils.ts @@ -3,33 +3,52 @@ import { Edge, Node, Position } from 'reactflow' import { AVAILABLE_REPLICA_REGIONS, - DatabaseConfiguration, NODE_ROW_HEIGHT, NODE_SEP, NODE_WIDTH, } from './InstanceConfiguration.constants' import { groupBy } from 'lodash' +import { Database } from 'data/read-replicas/replicas-query' export const generateNodes = ( - databases: DatabaseConfiguration[], + primary: Database, + replicas: Database[], { onSelectRestartReplica, onSelectResizeReplica, onSelectDropReplica, }: { - onSelectRestartReplica: (database: DatabaseConfiguration) => void - onSelectResizeReplica: (database: DatabaseConfiguration) => void - onSelectDropReplica: (database: DatabaseConfiguration) => void + onSelectRestartReplica: (database: Database) => void + onSelectResizeReplica: (database: Database) => void + onSelectDropReplica: (database: Database) => void } ): Node[] => { const position = { x: 0, y: 0 } - const replicas = databases.filter((d) => d.type === 'READ_REPLICA') const regions = groupBy(replicas, (d) => { const region = AVAILABLE_REPLICA_REGIONS.find((region) => d.region.includes(region.region)) return region?.key }) - const databaseNodes: Node[] = databases + const primaryRegion = AVAILABLE_REPLICA_REGIONS.find((region) => + primary.region.includes(region.region) + ) + const primaryNode: Node = { + position, + id: primary.identifier, + type: 'PRIMARY', + data: { + id: primary.identifier, + region: primaryRegion, + provider: primary.cloud_provider, + inserted_at: primary.inserted_at, + computeSize: primary.size, + status: primary.status, + numReplicas: replicas.length, + numRegions: Object.keys(regions).length, + }, + } + + const replicaNodes: Node[] = replicas .sort((a, b) => (a.region > b.region ? 1 : -1)) .map((database) => { const region = AVAILABLE_REPLICA_REGIONS.find((region) => @@ -38,33 +57,23 @@ export const generateNodes = ( return { position, - id: `database-${database.id}`, - type: database.type, + id: database.identifier, + type: 'READ_REPLICA', data: { - id: database.id, + id: database.identifier, region, - label: database.type === 'PRIMARY' ? 'Primary Database' : 'Read Replica', provider: database.cloud_provider, inserted_at: database.inserted_at, computeSize: database.size, - ...(database.type === 'READ_REPLICA' - ? { - onSelectRestartReplica: () => onSelectRestartReplica(database), - onSelectResizeReplica: () => onSelectResizeReplica(database), - onSelectDropReplica: () => onSelectDropReplica(database), - } - : {}), - ...(database.type === 'PRIMARY' - ? { - numReplicas: replicas.length, - numRegions: Object.keys(regions).length, - } - : {}), + status: database.status, + onSelectRestartReplica: () => onSelectRestartReplica(database), + onSelectResizeReplica: () => onSelectResizeReplica(database), + onSelectDropReplica: () => onSelectDropReplica(database), }, } }) - return [...databaseNodes] + return [primaryNode, ...replicaNodes] } export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => { @@ -123,7 +132,7 @@ export const addRegionNodes = (nodes: Node[], edges: Edge[]) => { const regionNode: Node = { id: key, position: { x: minX - 10, y: minY - 10 }, - width: maxX - minX, + width: maxX - minX + NODE_WIDTH / 2, type: 'REGION', data: { region, numReplicas: value.length }, } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 984820f9c75..1eba1d214fb 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -13,15 +13,16 @@ import { IconMoreVertical, } from 'ui' -import { BASE_PATH } from 'lib/constants' +import { BASE_PATH, PROJECT_STATUS } from 'lib/constants' import { NODE_SEP, NODE_WIDTH, Region } from './InstanceConfiguration.constants' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' interface NodeData { id: string - label: string provider: string region: Region computeSize: string + status: string inserted_at: string } @@ -37,7 +38,7 @@ interface ReplicaNodeData extends NodeData { } export const PrimaryNode = ({ data }: NodeProps) => { - const { label, provider, region, computeSize, numReplicas, numRegions } = data + const { provider, region, computeSize, numReplicas, numRegions } = data return ( <> @@ -51,7 +52,7 @@ export const PrimaryNode = ({ data }: NodeProps) => {
-

{label}

+

Primary Database

{region.name}

@@ -71,18 +72,25 @@ export const PrimaryNode = ({ data }: NodeProps) => { {numReplicas > 0 && (

- {numReplicas} replicas deployed across{' '} - {numRegions} regions + + {numReplicas} replica{numReplicas > 1 ? 's' : ''} + {' '} + deployed across{' '} + + {numRegions} region{numRegions > 1 ? 's' : ''} +

)}
- + {numReplicas > 0 && ( + + )} ) } @@ -90,10 +98,10 @@ export const PrimaryNode = ({ data }: NodeProps) => { export const ReplicaNode = ({ data }: NodeProps) => { const { id, - label, provider, region, computeSize, + status, inserted_at, onSelectRestartReplica, onSelectResizeReplica, @@ -120,16 +128,19 @@ export const ReplicaNode = ({ data }: NodeProps) => {
-

- {label} {id} +

+ Replica {id.length > 0 && `(ID: ${formatDatabaseID(id)})`}

- {/* [Joshen] Some status indication perhaps */} - Healthy + {status === PROJECT_STATUS.ACTIVE_HEALTHY ? ( + Healthy + ) : status === PROJECT_STATUS.COMING_UP ? ( + Coming up + ) : ( + Unhealthy + )}
-

- {region.name} -

+

{region.name}

{provider} @@ -150,12 +161,12 @@ export const ReplicaNode = ({ data }: NodeProps) => { View connection string - onSelectRestartReplica()}> + {/* onSelectRestartReplica()}> Restart replica - - onSelectResizeReplica()}> + */} + {/* onSelectResizeReplica()}> Resize replica - + */}

onSelectDropReplica()}> Drop replica diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index 023cdf06b62..15573e726f2 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -22,21 +22,19 @@ import { ScrollArea, } from 'ui' -import { AWS_REGIONS_KEYS, BASE_PATH } from 'lib/constants' -import { - AVAILABLE_REPLICA_REGIONS, - DatabaseConfiguration, - MOCK_DATABASES, -} from './InstanceConfiguration.constants' +import { AWS_REGIONS_KEYS, BASE_PATH, PROJECT_STATUS } from 'lib/constants' +import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants' import GeographyData from './MapData.json' +import { Database, useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { formatDatabaseID } from 'data/read-replicas/replicas.utils' // [Joshen] Foresee that we'll skip this view for initial launch interface MapViewProps { onSelectDeployNewReplica: (region: AWS_REGIONS_KEYS) => void - onSelectRestartReplica: (database: DatabaseConfiguration) => void - onSelectResizeReplica: (database: DatabaseConfiguration) => void - onSelectDropReplica: (database: DatabaseConfiguration) => void + onSelectRestartReplica: (database: Database) => void + onSelectResizeReplica: (database: Database) => void + onSelectDropReplica: (database: Database) => void } const MapView = ({ @@ -47,7 +45,7 @@ const MapView = ({ }: MapViewProps) => { const { ref } = useParams() const [mount, setMount] = useState(false) - const [zoom, setZoom] = useState(1) + const [zoom, setZoom] = useState(1.5) const [center, setCenter] = useState<[number, number]>([14, 7]) const [tooltip, setTooltip] = useState<{ x: number @@ -56,7 +54,10 @@ const MapView = ({ network?: any }>() - const [[primary], replicas] = partition(MOCK_DATABASES, (database) => database.type === 'PRIMARY') + const { data } = useReadReplicasQuery({ projectRef: ref }) + const databases = data ?? [] + const [[primary], replicas] = partition(databases, (db) => db.identifier === ref) + const primaryCoordinates = AVAILABLE_REPLICA_REGIONS.find((region) => primary.region.includes(region.region) )?.coordinates ?? [0, 0] @@ -66,16 +67,17 @@ const MapView = ({ const selectedRegionKey = AVAILABLE_REPLICA_REGIONS.find((region) => region.coordinates === center)?.region ?? '' - const showRegionDetails = zoom === 1.5 && selectedRegionKey !== undefined + const showRegionDetails = zoom === 2.0 && selectedRegionKey !== undefined const selectedRegion = AVAILABLE_REPLICA_REGIONS.find( (region) => region.region === selectedRegionKey ) const databasesInSelectedRegion = useMemo( () => - MOCK_DATABASES.filter((database) => database.region.includes(selectedRegionKey)) - .sort((a, b) => (a.id > b.id ? 1 : 0)) - .sort((database) => (database.type === 'PRIMARY' ? -1 : 0)), - [selectedRegionKey] + databases + .filter((database) => database.region.includes(selectedRegionKey)) + .sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0)) + .sort((database) => (database.identifier === ref ? -1 : 0)), + [ref, selectedRegionKey] ) useEffect(() => { @@ -83,14 +85,14 @@ const MapView = ({ }, []) return ( -
- +
+ !['MouseEvent', 'WheelEvent'].includes(name) } @@ -117,7 +119,7 @@ const MapView = ({ if (coordinates !== primaryCoordinates) { return ( { - const databases = - MOCK_DATABASES.filter((database) => database.region.includes(region.region)) ?? [] + const dbs = + databases.filter((database) => database.region.includes(region.region)) ?? [] const coordinates = AVAILABLE_REPLICA_REGIONS.find( (r) => r.region === region.region )?.coordinates - const hasNoDatabases = databases.length === 0 - const hasPrimary = databases.some((database) => database.type === 'PRIMARY') - const replicas = databases.filter((database) => database.type === 'READ_REPLICA') ?? [] + const hasNoDatabases = dbs.length === 0 + const hasPrimary = dbs.some((database) => database.identifier === ref) + const replicas = dbs.filter((database) => database.identifier !== ref) ?? [] return ( setTooltip({ x: event.clientX, - y: event.clientY, + y: event.clientY + 20, region: { key: region.key, country: region.name, @@ -159,7 +161,9 @@ const MapView = ({ ? undefined : hasPrimary ? `Primary Database${ - replicas.length > 0 ? ` + ${replicas.length} replicas` : '' + replicas.length > 0 + ? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} ` + : '' }` : `${replicas.length} Read Replica${ replicas.length > 1 ? 's' : '' @@ -171,7 +175,7 @@ const MapView = ({ onClick={() => { if (coordinates) { setCenter(coordinates) - setZoom(1.5) + setZoom(2.0) } }} > @@ -247,22 +251,31 @@ const MapView = ({ return (
  • - {database.type === 'PRIMARY' + {database.identifier === ref ? 'Primary Database' - : `Read Replica (ID: ${database.id})`} - {database.type === 'READ_REPLICA' && Healthy} + : `Read Replica ${ + database.identifier.length > 0 && + `(ID: ${formatDatabaseID(database.identifier)})` + }`} + {database.status === PROJECT_STATUS.ACTIVE_HEALTHY ? ( + Healthy + ) : database.status === PROJECT_STATUS.COMING_UP ? ( + Coming up + ) : ( + Unhealthy + )}

    AWS • {database.size}

    - {database.type === 'READ_REPLICA' && ( + {database.identifier !== ref && (

    Created on: {created}

    )}
    - {database.type === 'READ_REPLICA' && ( + {database.identifier !== ref && (
  • - + 7 ? 'h-[210px]' : ''}> {databases?.map((database) => { + const region = formatDatabaseRegion(database.region) + const id = formatDatabaseID(database.identifier) + return ( { - onChangeDatabaseId(database.id.toString()) + onChangeDatabaseId(database.identifier) setOpen(false) }} onClick={() => { - onChangeDatabaseId(database.id.toString()) + onChangeDatabaseId(database.identifier) setOpen(false) }} >

    - {database.type === 'PRIMARY' + {database.identifier === projectRef ? 'Primary database' - : `Read replica (ID: ${database.id})`} + : `Read replica (${region} - ${id})`}

    - {database.id.toString() === selectedDatabaseId && } + {database.identifier === selectedDatabaseId && }
    ) diff --git a/apps/studio/data/api.d.ts b/apps/studio/data/api.d.ts index 99b8f213e87..b83418de24d 100644 --- a/apps/studio/data/api.d.ts +++ b/apps/studio/data/api.d.ts @@ -467,6 +467,14 @@ export interface paths { /** Gets daily project stats */ get: operations['DailyStatsController_getDailyStats'] } + '/platform/projects/{ref}/databases': { + /** Gets non-removed databases of a specified project */ + get: operations['DatabasesController_getDatabases'] + } + '/platform/projects/{ref}/databases-statuses': { + /** Gets status of all databases within a project */ + get: operations['DatabasesStatusesController_getStatus'] + } '/platform/projects/{ref}/db-password': { /** Updates the database password */ patch: operations['DbPasswordController_updatePassword'] @@ -1213,6 +1221,14 @@ export interface paths { /** Gets daily project stats */ get: operations['DailyStatsController_getDailyStats'] } + '/v0/projects/{ref}/databases': { + /** Gets non-removed databases of a specified project */ + get: operations['DatabasesController_getDatabases'] + } + '/v0/projects/{ref}/databases-statuses': { + /** Gets status of all databases within a project */ + get: operations['DatabasesStatusesController_getStatus'] + } '/v0/projects/{ref}/db-password': { /** Updates the database password */ patch: operations['DbPasswordController_updatePassword'] @@ -3549,6 +3565,45 @@ export interface components { content?: Record owner_id?: number } + DatabaseDetailResponse: { + db_port: number + db_name: string + db_user: string + restUrl: string + db_host: string + connectionString: string + identifier: string + inserted_at: string + /** @enum {string} */ + status: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTORING' + | 'UNKNOWN' + | 'UPGRADING' + size: string + region: string + /** @enum {string} */ + cloud_provider: 'AWS' | 'FLY' + } + DatabaseStatusResponse: { + identifier: string + /** @enum {string} */ + status: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'REMOVED' + | 'RESTORING' + | 'UNKNOWN' + | 'UPGRADING' + } UpdatePasswordBody: { password: string } @@ -8408,6 +8463,42 @@ export interface operations { } } } + /** Gets non-removed databases of a specified project */ + DatabasesController_getDatabases: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['DatabaseDetailResponse'][] + } + } + } + } + /** Gets status of all databases within a project */ + DatabasesStatusesController_getStatus: { + parameters: { + path: { + /** @description Project ref */ + ref: string + } + } + responses: { + 200: { + content: { + 'application/json': components['schemas']['DatabaseStatusResponse'][] + } + } + /** @description Failed to get project's status */ + 500: { + content: never + } + } + } /** Updates the database password */ DbPasswordController_updatePassword: { parameters: { diff --git a/apps/studio/data/read-replicas/keys.ts b/apps/studio/data/read-replicas/keys.ts new file mode 100644 index 00000000000..1af4e2a04f2 --- /dev/null +++ b/apps/studio/data/read-replicas/keys.ts @@ -0,0 +1,5 @@ +export const replicaKeys = { + list: (projectRef: string | undefined) => ['project', projectRef, 'replicas'] as const, + statuses: (projectRef: string | undefined) => + ['project', projectRef, 'replicas-statuses'] as const, +} diff --git a/apps/studio/data/read-replicas/replica-remove-mutation.ts b/apps/studio/data/read-replicas/replica-remove-mutation.ts new file mode 100644 index 00000000000..4c8d016d98d --- /dev/null +++ b/apps/studio/data/read-replicas/replica-remove-mutation.ts @@ -0,0 +1,66 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicaKeys } from './keys' +import { Database } from './replicas-query' + +export type ReadReplicaRemoveVariables = { + projectRef: string + identifier: string +} + +export async function removeReadReplica({ projectRef, identifier }: ReadReplicaRemoveVariables) { + const { data, error } = await post('/v1/projects/{ref}/read-replicas/remove', { + params: { + path: { ref: projectRef }, + }, + body: { + database_identifier: identifier, + }, + }) + + if (error) throw error + return data +} + +type ReadReplicaRemoveData = Awaited> + +export const useReadReplicaRemoveMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => removeReadReplica(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, identifier } = variables + + // [Joshen] Just FYI, will remove this once API changes to remove the need for optimistic rendering + queryClient.setQueriesData(replicaKeys.list(projectRef), (old: any) => { + return old.filter((db: Database) => db.identifier !== identifier) + }) + + setTimeout(async () => { + await queryClient.invalidateQueries(replicaKeys.list(projectRef)) + }, 5000) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to remove read replica: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/read-replicas/replica-setup-mutation.ts b/apps/studio/data/read-replicas/replica-setup-mutation.ts new file mode 100644 index 00000000000..157ffd06023 --- /dev/null +++ b/apps/studio/data/read-replicas/replica-setup-mutation.ts @@ -0,0 +1,96 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import { toast } from 'react-hot-toast' + +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicaKeys } from './keys' +import { Database } from './replicas-query' + +export type Region = + | 'us-east-1' + | 'us-west-1' + | 'us-west-2' + | 'ap-southeast-1' + | 'ap-northeast-1' + | 'ap-northeast-2' + | 'ap-southeast-2' + | 'eu-west-1' + | 'eu-west-2' + | 'eu-west-3' + | 'eu-central-1' + | 'ca-central-1' + | 'ap-south-1' + | 'sa-east-1' + +export type ReadReplicaSetUpVariables = { + projectRef: string + region: Region + size: string // Not used in API yet, purely for UI atm +} + +export async function setUpReadReplica({ projectRef, region }: ReadReplicaSetUpVariables) { + const { data, error } = await post('/v1/projects/{ref}/read-replicas/setup', { + params: { + path: { ref: projectRef }, + }, + body: { + read_replica_region: region, + }, + }) + if (error) throw error + return data +} + +type ReadReplicaSetUpData = Awaited> + +export const useReadReplicaSetUpMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + return useMutation( + (vars) => setUpReadReplica(vars), + { + async onSuccess(data, variables, context) { + const { projectRef, region, size } = variables + + // [Joshen] Just FYI, will remove this once API changes to remove the need for optimistic rendering + queryClient.setQueriesData(replicaKeys.list(projectRef), (old: any) => { + const scaffoldNewDatabase: Database = { + db_port: 5432, + db_name: 'postgres', + db_user: 'postgres', + restUrl: '', + db_host: '', + connectionString: '', + identifier: `${projectRef}-rr-${region}-None`, + size, + region, + inserted_at: new Date().toISOString(), + status: 'COMING_UP', + cloud_provider: 'AWS', + } + return [...old, scaffoldNewDatabase] + }) + + setTimeout(async () => { + await queryClient.invalidateQueries(replicaKeys.list(projectRef)) + }, 5000) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to set up read replica: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/read-replicas/replicas-query.ts b/apps/studio/data/read-replicas/replicas-query.ts new file mode 100644 index 00000000000..c7efd05fc55 --- /dev/null +++ b/apps/studio/data/read-replicas/replicas-query.ts @@ -0,0 +1,39 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicaKeys } from './keys' +import { components } from 'data/api' +import { useFlag } from 'hooks' + +export type ReadReplicasVariables = { + projectRef?: string +} + +export type Database = components['schemas']['DatabaseDetailResponse'] + +export async function getReadReplicas({ projectRef }: ReadReplicasVariables, signal?: AbortSignal) { + if (!projectRef) throw new Error('Project ref is required') + + const { data, error } = await get(`/platform/projects/{ref}/databases`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) throw error + return data +} + +export type ReadReplicasData = Awaited> +export type ReadReplicasError = ResponseError + +export const useReadReplicasQuery = ( + { projectRef }: ReadReplicasVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + const readReplicasEnabled = useFlag('readReplicas') + return useQuery( + replicaKeys.list(projectRef), + ({ signal }) => getReadReplicas({ projectRef }, signal), + { enabled: enabled && readReplicasEnabled && typeof projectRef !== 'undefined', ...options } + ) +} diff --git a/apps/studio/data/read-replicas/replicas-status-query.ts b/apps/studio/data/read-replicas/replicas-status-query.ts new file mode 100644 index 00000000000..64aba5d293a --- /dev/null +++ b/apps/studio/data/read-replicas/replicas-status-query.ts @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { replicaKeys } from './keys' +import { useFlag } from 'hooks' + +export type ReadReplicasStatusesVariables = { + projectRef?: string +} + +export async function getReadReplicasStatuses( + { projectRef }: ReadReplicasStatusesVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('Project ref is required') + + const { data, error } = await get(`/platform/projects/{ref}/databases-statuses`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) throw error + return data +} + +export type ReadReplicasStatusesData = Awaited> +export type ReadReplicasStatusesError = ResponseError + +export const useReadReplicasStatusesQuery = ( + { projectRef }: ReadReplicasStatusesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + const readReplicasEnabled = useFlag('readReplicas') + useQuery( + replicaKeys.statuses(projectRef), + ({ signal }) => getReadReplicasStatuses({ projectRef }, signal), + { enabled: enabled && readReplicasEnabled && typeof projectRef !== 'undefined', ...options } + ) +} diff --git a/apps/studio/data/read-replicas/replicas.utils.ts b/apps/studio/data/read-replicas/replicas.utils.ts new file mode 100644 index 00000000000..c80bc0454a1 --- /dev/null +++ b/apps/studio/data/read-replicas/replicas.utils.ts @@ -0,0 +1,7 @@ +import { AVAILABLE_REPLICA_REGIONS } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants' +import { last } from 'lodash' + +export const formatDatabaseID = (id: string) => last(id.split('-') ?? []) + +export const formatDatabaseRegion = (region: string) => + last(AVAILABLE_REPLICA_REGIONS.find((r) => r.region === region)?.name.split('('))?.split(')')[0] diff --git a/apps/studio/state/sql-editor.ts b/apps/studio/state/sql-editor.ts index fdc98e1b0ab..20abda4abab 100644 --- a/apps/studio/state/sql-editor.ts +++ b/apps/studio/state/sql-editor.ts @@ -36,6 +36,11 @@ export const sqlEditorState = proxy({ [key: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED' }, + selectedDatabaseId: undefined as string | undefined, + setSelectedDatabaseId: (value?: string) => { + sqlEditorState.selectedDatabaseId = value + }, + orderSnippets: (snippets: SqlSnippet[]) => { return ( snippets