diff --git a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx index 602bb1ef820..37ba646a136 100644 --- a/apps/studio/components/interfaces/Settings/API/ServiceList.tsx +++ b/apps/studio/components/interfaces/Settings/API/ServiceList.tsx @@ -20,12 +20,13 @@ import JWTSettings from './JWTSettings' import PostgrestConfig from './PostgrestConfig' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { PROJECT_STATUS } from 'lib/constants' +import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' const ServiceList = () => { const { ui } = useStore() const client = useQueryClient() const { project, isLoading } = useProjectContext() - const { ref: projectRef } = useParams() + const { ref: projectRef, source } = useParams() const state = useDatabaseSelectorStateSnapshot() const readReplicasEnabled = useFlag('readReplicas') @@ -36,6 +37,7 @@ const ServiceList = () => { }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const { data: databases } = useReadReplicasQuery({ projectRef }) + const { data: loadBalancers } = useLoadBalancersQuery({ projectRef }) const { data } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus @@ -46,6 +48,26 @@ const ServiceList = () => { const jwtSecretUpdateErrorMessage = JWT_SECRET_UPDATE_ERROR_MESSAGES[jwtSecretUpdateError as JwtSecretUpdateError] + // Get the API service + const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' + const apiService = settings?.autoApiService + const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` + + const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) + const loadBalancerSelected = state.selectedDatabaseId === 'load-balancer' + const replicaSelected = selectedDatabase?.identifier !== projectRef + + const primaryEndpoint = isCustomDomainActive + ? `https://${customDomainData.customDomain.hostname}` + : apiUrl + const endpoint = !showReadReplicasUI + ? primaryEndpoint + : isCustomDomainActive && state.selectedDatabaseId === projectRef + ? `https://${customDomainData.customDomain.hostname}` + : loadBalancerSelected + ? loadBalancers?.[0].endpoint ?? '' + : selectedDatabase?.restUrl + useEffect(() => { if (previousJwtSecretUpdateStatus.current === Updating) { switch (jwtSecretUpdateStatus) { @@ -68,21 +90,11 @@ const ServiceList = () => { previousJwtSecretUpdateStatus.current = jwtSecretUpdateStatus }, [jwtSecretUpdateStatus]) - // Get the API service - const isCustomDomainActive = customDomainData?.customDomain?.status === 'active' - const apiService = settings?.autoApiService - const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` - - const selectedDatabase = databases?.find((db) => db.identifier === state.selectedDatabaseId) - - const primaryEndpoint = isCustomDomainActive - ? `https://${customDomainData.customDomain.hostname}` - : apiUrl - const endpoint = !showReadReplicasUI - ? primaryEndpoint - : isCustomDomainActive && state.selectedDatabaseId === projectRef - ? `https://${customDomainData.customDomain.hostname}` - : selectedDatabase?.restUrl + useEffect(() => { + if (readReplicasEnabled && source !== undefined) { + state.setSelectedDatabaseId('load-balancer') + } + }, [source]) return (
@@ -106,7 +118,15 @@ const ServiceList = () => { title={
Project URL
- {showReadReplicasUI && } + {showReadReplicasUI && ( + 0 + ? [{ id: 'load-balancer', name: 'API Load Balancer' }] + : [] + } + /> + )}
} > @@ -133,7 +153,13 @@ const ServiceList = () => { disabled className="input-mono" value={endpoint} - descriptionText="A RESTful endpoint for querying and managing your database." + descriptionText={ + loadBalancerSelected + ? 'A RESTful endpoint for querying and managing your databases through your load balancer' + : replicaSelected && showReadReplicasUI + ? 'A RESTful endpoint for querying your read replica' + : 'A RESTful endpoint for querying and managing your database' + } layout="horizontal" /> )} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 572d0a6cb98..6325f596b89 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -14,8 +14,9 @@ import { AWS_REGIONS_KEYS } from 'lib/constants' import DeployNewReplicaPanel from './DeployNewReplicaPanel' import DropReplicaConfirmationModal from './DropReplicaConfirmationModal' import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils' -import { PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' +import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' +import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' // [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 @@ -35,7 +36,17 @@ const InstanceConfigurationUI = () => { const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState() const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState() - const { data, error, refetch, isLoading, isError, isSuccess } = useReadReplicasQuery({ + const { data: loadBalancers, isSuccess: isSuccessLoadBalancers } = useLoadBalancersQuery({ + projectRef, + }) + const { + data, + error, + refetch, + isLoading, + isError, + isSuccess: isSuccessReplicas, + } = useReadReplicasQuery({ projectRef, }) const [[primary], replicas] = useMemo( @@ -71,28 +82,46 @@ const InstanceConfigurationUI = () => { const nodes = useMemo( () => - isSuccess - ? generateNodes(primary, replicas, { + isSuccessReplicas && isSuccessLoadBalancers + ? generateNodes(primary, replicas, loadBalancers ?? [], { onSelectRestartReplica: setSelectedReplicaToRestart, onSelectResizeReplica: setSelectedReplicaToResize, onSelectDropReplica: setSelectedReplicaToDrop, }) : [], - [isSuccess, primary, replicas] + [isSuccessReplicas, isSuccessLoadBalancers] ) - const edges: Edge[] = replicas.map((database) => { - return { - id: `${primary.identifier}-${database.identifier}`, - source: primary.identifier, - target: database.identifier, - type: 'smoothstep', - animated: true, - } - }) + const edges: Edge[] = [ + ...((loadBalancers ?? []).length > 0 + ? [ + { + id: `load-balancer-${primary.identifier}`, + source: 'load-balancer', + target: primary.identifier, + type: 'smoothstep', + animated: true, + }, + ] + : []), + ...replicas.map((database) => { + return { + id: `${primary.identifier}-${database.identifier}`, + source: primary.identifier, + target: database.identifier, + type: 'smoothstep', + animated: true, + } + }), + ] const nodeTypes = useMemo( - () => ({ PRIMARY: PrimaryNode, READ_REPLICA: ReplicaNode, REGION: RegionNode }), + () => ({ + PRIMARY: PrimaryNode, + READ_REPLICA: ReplicaNode, + REGION: RegionNode, + LOAD_BALANCER: LoadBalancerNode, + }), [] ) @@ -107,24 +136,24 @@ const InstanceConfigurationUI = () => { // [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) { + if (isSuccessReplicas && isSuccessLoadBalancers && replicas.length > 0) { const graph = getDagreGraphLayout(nodes, edges) const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges) reactFlow.setNodes(updatedNodes) reactFlow.setEdges(graph.edges) } - }, [replicas]) + }, [isSuccessReplicas, isSuccessLoadBalancers]) return ( <>
{isLoading && } {isError && } - {isSuccess && ( + {isSuccessReplicas && ( <>
+
+ + + ) +} + +export const PrimaryNode = ({ data }: NodeProps) => { + const { provider, region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data + + return ( + <> + {hasLoadBalancer && ( + + )}
) => {

Primary Database

- {region.name} + {region.name}

- {provider} - • - {computeSize} + {provider} + • + {computeSize}

@@ -119,7 +178,7 @@ export const ReplicaNode = ({ data }: NodeProps) => { style={{ background: 'transparent' }} />
@@ -140,40 +199,38 @@ export const ReplicaNode = ({ data }: NodeProps) => { )}
-

{region.name}

-

+

{region.name}

+

{provider} • {computeSize}

-

Created: {created}

+

Created: {created}

-
- - -
) @@ -187,7 +244,7 @@ export const RegionNode = ({ data }: any) => { return (
void } const DatabaseSelector = ({ variant = 'regular', + additionalOptions = [], selectedId, setSelectedId, }: DatabaseSelectorProps) => { @@ -47,6 +49,8 @@ const DatabaseSelector = ({ const selectedDatabaseRegion = formatDatabaseRegion(selectedDatabase?.region ?? '') const formattedDatabaseId = formatDatabaseID(selectedDatabaseId ?? '') + const selectedAdditionalOption = additionalOptions.find((x) => x.id === selectedDatabaseId) + return ( @@ -64,13 +68,21 @@ const DatabaseSelector = ({ } > Source:{' '} - - {selectedDatabase?.identifier === projectRef ? 'Primary database' : 'Read replica'} - {' '} - {selectedDatabase?.identifier !== projectRef && ( - - ({selectedDatabaseRegion} - {formattedDatabaseId}) - + {selectedAdditionalOption ? ( + {selectedAdditionalOption.name} + ) : ( + <> + + {selectedDatabase?.identifier === projectRef + ? 'Primary database' + : 'Read replica'} + {' '} + {selectedDatabase?.identifier !== projectRef && ( + + ({selectedDatabaseRegion} - {formattedDatabaseId}) + + )} + )}
@@ -78,6 +90,30 @@ const DatabaseSelector = ({ + {additionalOptions.length > 0 && ( + + {additionalOptions.map((option) => ( + { + state.setSelectedDatabaseId(option.id) + setOpen(false) + }} + onClick={() => { + state.setSelectedDatabaseId(option.id) + setOpen(false) + }} + > +
+

{option.name}

+ {option.id === selectedDatabaseId && } +
+
+ ))} +
+ )} 7 ? 'h-[210px]' : ''}> {databases?.map((database) => { diff --git a/apps/studio/data/read-replicas/keys.ts b/apps/studio/data/read-replicas/keys.ts index 1af4e2a04f2..26b8cbd816d 100644 --- a/apps/studio/data/read-replicas/keys.ts +++ b/apps/studio/data/read-replicas/keys.ts @@ -2,4 +2,6 @@ export const replicaKeys = { list: (projectRef: string | undefined) => ['project', projectRef, 'replicas'] as const, statuses: (projectRef: string | undefined) => ['project', projectRef, 'replicas-statuses'] as const, + loadBalancers: (projectRef: string | undefined) => + ['project', projectRef, 'load-balancers'] as const, } diff --git a/apps/studio/data/read-replicas/load-balancers-query.ts b/apps/studio/data/read-replicas/load-balancers-query.ts new file mode 100644 index 00000000000..5d610e7d58e --- /dev/null +++ b/apps/studio/data/read-replicas/load-balancers-query.ts @@ -0,0 +1,52 @@ +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' +import { useProjectDetailQuery } from 'data/projects/project-detail-query' + +export type LoadBalancersVariables = { + projectRef?: string +} + +export type LoadBalancer = components['schemas']['LoadBalancerDetailResponse'] + +export async function getLoadBalancers( + { projectRef }: LoadBalancersVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('Project ref is required') + + const { data, error } = await get(`/platform/projects/{ref}/load-balancers`, { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) throw new Error((error as ResponseError).message) + return data +} + +export type LoadBalancersData = Awaited> +export type LoadBalancersError = ResponseError + +export const useLoadBalancersQuery = ( + { projectRef }: LoadBalancersVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + const readReplicasEnabled = useFlag('readReplicas') + const { data } = useProjectDetailQuery({ ref: projectRef }) + + return useQuery( + replicaKeys.loadBalancers(projectRef), + ({ signal }) => getLoadBalancers({ projectRef }, signal), + { + enabled: + enabled && + data?.is_read_replicas_enabled && + readReplicasEnabled && + typeof projectRef !== 'undefined', + ...options, + } + ) +}