Chore/expose load balancer api endpoint in api settings (#21314)

* Expose load balancer API endpoint in API settings

* Add load balancer as a node in infrastructure config

* Add load balancer node to infra config

* Fix
This commit is contained in:
Joshen Lim authored and GitHub committed 2024-02-21 16:58:15 +08:00
1 parent b025cc3810
commit fc405ce035
7 files changed
+312 -82

No files matched your search

@@ -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 (
<div>
@@ -106,7 +118,15 @@ const ServiceList = () => {
title={
<div className="w-full flex items-center justify-between">
<h5 className="mb-0">Project URL</h5>
{showReadReplicasUI && <DatabaseSelector />}
{showReadReplicasUI && (
<DatabaseSelector
additionalOptions={
(loadBalancers ?? []).length > 0
? [{ id: 'load-balancer', name: 'API Load Balancer' }]
: []
}
/>
)}
</div>
}
>
@@ -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"
/>
)}
@@ -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<Database>()
const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState<Database>()
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 (
<>
<div
className={`h-[500px] w-full relative ${
isSuccess ? '' : 'flex items-center justify-center px-28'
isSuccessReplicas ? '' : 'flex items-center justify-center px-28'
}`}
>
{isLoading && <Loader2 className="animate-spin text-foreground-light" />}
{isError && <AlertError error={error} subject="Failed to retrieve replicas" />}
{isSuccess && (
{isSuccessReplicas && (
<>
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
<Button type="default" onClick={() => setShowNewReplicaPanel(true)}>
@@ -152,7 +181,7 @@ const InstanceConfigurationUI = () => {
{view === 'flow' ? (
<ReactFlow
fitView
fitViewOptions={{ minZoom: 0.9, maxZoom: 1 }}
fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9 }}
className="instance-configuration"
zoomOnPinch={false}
zoomOnScroll={false}
@@ -9,10 +9,15 @@ import {
} from './InstanceConfiguration.constants'
import { groupBy } from 'lodash'
import { Database } from 'data/read-replicas/replicas-query'
import { LoadBalancer } from 'data/read-replicas/load-balancers-query'
// [Joshen] Just FYI the nodes generation assumes each project only has one load balancer
// Will need to change if this eventually becomes otherwise
export const generateNodes = (
primary: Database,
replicas: Database[],
loadBalancers: LoadBalancer[],
{
onSelectRestartReplica,
onSelectResizeReplica,
@@ -29,6 +34,21 @@ export const generateNodes = (
return region?.key
})
const loadBalancer = loadBalancers.find((x) =>
x.databases.some((db) => db.identifier === primary.identifier)
)
const loadBalancerNode: Node | undefined =
loadBalancer !== undefined
? {
position,
id: 'load-balancer',
type: 'LOAD_BALANCER',
data: {
numDatabases: loadBalancer.databases.length,
},
}
: undefined
const primaryRegion = AVAILABLE_REPLICA_REGIONS.find((region) =>
primary.region.includes(region.region)
)
@@ -45,6 +65,7 @@ export const generateNodes = (
status: primary.status,
numReplicas: replicas.length,
numRegions: Object.keys(regions).length,
hasLoadBalancer: loadBalancer !== undefined,
},
}
@@ -73,17 +94,24 @@ export const generateNodes = (
}
})
return [primaryNode, ...replicaNodes]
return [
...(loadBalancerNode !== undefined ? [loadBalancerNode] : []),
primaryNode,
...replicaNodes,
]
}
export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 200, nodesep: NODE_SEP })
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 160, nodesep: NODE_SEP })
nodes.forEach((node) =>
dagreGraph.setNode(node.id, { width: NODE_WIDTH / 2, height: NODE_ROW_HEIGHT / 2 })
)
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
width: NODE_WIDTH / 2,
height: node.id === 'load-balancer' ? -70 : NODE_ROW_HEIGHT / 2,
})
})
edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target))
@@ -9,13 +9,14 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
IconMoreVertical,
} from 'ui'
import { formatDatabaseID } from 'data/read-replicas/replicas.utils'
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
@@ -29,6 +30,11 @@ interface NodeData {
interface PrimaryNodeData extends NodeData {
numReplicas: number
numRegions: number
hasLoadBalancer: boolean
}
interface LoadBalancerData extends NodeData {
numDatabases: number
}
interface ReplicaNodeData extends NodeData {
@@ -37,11 +43,64 @@ interface ReplicaNodeData extends NodeData {
onSelectDropReplica: () => void
}
export const PrimaryNode = ({ data }: NodeProps<PrimaryNodeData>) => {
const { provider, region, computeSize, numReplicas, numRegions } = data
export const LoadBalancerNode = ({ data }: NodeProps<LoadBalancerData>) => {
const { ref } = useParams()
const { numDatabases } = data
return (
<>
<div className="flex flex-col rounded bg-surface-100 border border-default">
<div
className="flex items-start justify-between p-3 gap-x-4"
style={{ width: NODE_WIDTH / 2 - 10 }}
>
<div className="flex gap-x-3">
<div className="min-w-8 h-8 bg-blue-600 border border-blue-800 rounded-md flex items-center justify-center">
<Database size={16} />
</div>
<div className="flex flex-col gap-y-0.5">
<p className="text-sm">API Load Balancer</p>
<p className="text-sm text-foreground-light">
Distributes incoming API requests across{' '}
<span className="text-foreground">{numDatabases} databases</span>
</p>
</div>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" side="bottom" align="end">
<DropdownMenuItem asChild className="gap-x-2">
<Link href={`/project/${ref}/settings/api?source=loadbalancer`}>View API URL</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<Handle
type="source"
id="handle-b"
position={Position.Bottom}
style={{ background: 'transparent' }}
/>
</>
)
}
export const PrimaryNode = ({ data }: NodeProps<PrimaryNodeData>) => {
const { provider, region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
return (
<>
{hasLoadBalancer && (
<Handle
type="target"
id="handle-t"
position={Position.Top}
style={{ background: 'transparent' }}
/>
)}
<div className="flex flex-col rounded bg-surface-100 border border-default">
<div
className="flex items-start justify-between p-3"
@@ -54,12 +113,12 @@ export const PrimaryNode = ({ data }: NodeProps<PrimaryNodeData>) => {
<div className="flex flex-col gap-y-0.5">
<p className="text-sm">Primary Database</p>
<p className="flex items-center gap-x-1">
<span className="text-xs text-foreground-light">{region.name}</span>
<span className="text-sm text-foreground-light">{region.name}</span>
</p>
<p className="flex items-center gap-x-1">
<span className="text-xs text-foreground-light">{provider}</span>
<span className="text-xs text-foreground-light">•</span>
<span className="text-xs text-foreground-light">{computeSize}</span>
<span className="text-sm text-foreground-light">{provider}</span>
<span className="text-sm text-foreground-light">•</span>
<span className="text-sm text-foreground-light">{computeSize}</span>
</p>
</div>
</div>
@@ -119,7 +178,7 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
style={{ background: 'transparent' }}
/>
<div
className="flex justify-between rounded bg-surface-100 border border-default p-3"
className="flex justify-between items-start rounded bg-surface-100 border border-default p-3"
style={{ width: NODE_WIDTH / 2 - 10 }}
>
<div className="flex gap-x-3">
@@ -140,40 +199,38 @@ export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
)}
</div>
<div className="my-0.5">
<p className="text-xs text-foreground-light">{region.name}</p>
<p className="flex text-xs text-foreground-light items-center gap-x-1">
<p className="text-sm text-foreground-light">{region.name}</p>
<p className="flex text-sm text-foreground-light items-center gap-x-1">
<span>{provider}</span>
<span>•</span>
<span>{computeSize}</span>
</p>
</div>
<p className="text-xs text-foreground-light">Created: {created}</p>
<p className="text-sm text-foreground-light">Created: {created}</p>
</div>
</div>
<div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0 w-40" side="bottom" align="end">
<DropdownMenuItem className="gap-x-2">
<Link href={`/project/${ref}/settings/database?connectionString=${id}`}>
View connection string
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem className="gap-x-2" onClick={() => onSelectRestartReplica()}>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" side="bottom" align="end">
<DropdownMenuItem className="gap-x-2">
<Link href={`/project/${ref}/settings/database?connectionString=${id}`}>
View connection string
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem className="gap-x-2" onClick={() => onSelectRestartReplica()}>
Restart replica
</DropdownMenuItem> */}
{/* <DropdownMenuItem className="gap-x-2" onClick={() => onSelectResizeReplica()}>
{/* <DropdownMenuItem className="gap-x-2" onClick={() => onSelectResizeReplica()}>
Resize replica
</DropdownMenuItem> */}
<div className="border-t" />
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectDropReplica()}>
Drop replica
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectDropReplica()}>
Drop replica
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
)
@@ -187,7 +244,7 @@ export const RegionNode = ({ data }: any) => {
return (
<div
className="relative flex justify-between rounded bg-black/10 border border-default border-white/10 border-2 p-3"
style={{ width: regionNodeWidth, height: 150 }}
style={{ width: regionNodeWidth, height: 162 }}
>
<div className="absolute bottom-2 flex items-center justify-between gap-x-2">
<img
+43 -7
View File
@@ -24,12 +24,14 @@ import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
interface DatabaseSelectorProps {
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
additionalOptions?: { id: string; name: string }[]
selectedId?: string // Optional to let parent component control its state (e.g Database Settings page)
setSelectedId?: (value: string) => 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 (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
@@ -64,13 +68,21 @@ const DatabaseSelector = ({
}
>
Source:{' '}
<span className="capitalize">
{selectedDatabase?.identifier === projectRef ? 'Primary database' : 'Read replica'}
</span>{' '}
{selectedDatabase?.identifier !== projectRef && (
<span>
({selectedDatabaseRegion} - {formattedDatabaseId})
</span>
{selectedAdditionalOption ? (
<span>{selectedAdditionalOption.name}</span>
) : (
<>
<span className="capitalize">
{selectedDatabase?.identifier === projectRef
? 'Primary database'
: 'Read replica'}
</span>{' '}
{selectedDatabase?.identifier !== projectRef && (
<span>
({selectedDatabaseRegion} - {formattedDatabaseId})
</span>
)}
</>
)}
</Button>
</div>
@@ -78,6 +90,30 @@ const DatabaseSelector = ({
<PopoverContent_Shadcn_ className="p-0 w-64" side="bottom" align="end">
<Command_Shadcn_>
<CommandList_Shadcn_>
{additionalOptions.length > 0 && (
<CommandGroup_Shadcn_ className="border-b">
{additionalOptions.map((option) => (
<CommandItem_Shadcn_
key={option.id}
value={option.id}
className="cursor-pointer w-full"
onSelect={() => {
state.setSelectedDatabaseId(option.id)
setOpen(false)
}}
onClick={() => {
state.setSelectedDatabaseId(option.id)
setOpen(false)
}}
>
<div className="w-full flex items-center justify-between">
<p>{option.name}</p>
{option.id === selectedDatabaseId && <IconCheck />}
</div>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
)}
<CommandGroup_Shadcn_>
<ScrollArea className={(databases || []).length > 7 ? 'h-[210px]' : ''}>
{databases?.map((database) => {
+2
View File
@@ -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,
}
@@ -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<ReturnType<typeof getLoadBalancers>>
export type LoadBalancersError = ResponseError
export const useLoadBalancersQuery = <TData = LoadBalancersData>(
{ projectRef }: LoadBalancersVariables,
{ enabled = true, ...options }: UseQueryOptions<LoadBalancersData, LoadBalancersError, TData> = {}
) => {
const readReplicasEnabled = useFlag('readReplicas')
const { data } = useProjectDetailQuery({ ref: projectRef })
return useQuery<LoadBalancersData, LoadBalancersError, TData>(
replicaKeys.loadBalancers(projectRef),
({ signal }) => getLoadBalancers({ projectRef }, signal),
{
enabled:
enabled &&
data?.is_read_replicas_enabled &&
readReplicasEnabled &&
typeof projectRef !== 'undefined',
...options,
}
)
}