RegionSelector.tsx315 lines · main
| 1 | import { useFlag, useParams } from 'common' |
| 2 | import { UseFormReturn } from 'react-hook-form' |
| 3 | import type { CloudProvider } from 'shared-data' |
| 4 | import { |
| 5 | Badge, |
| 6 | cn, |
| 7 | FormField, |
| 8 | Select, |
| 9 | SelectContent, |
| 10 | SelectGroup, |
| 11 | SelectItem, |
| 12 | SelectLabel, |
| 13 | SelectSeparator, |
| 14 | SelectTrigger, |
| 15 | SelectValue, |
| 16 | Tooltip, |
| 17 | TooltipContent, |
| 18 | TooltipTrigger, |
| 19 | } from 'ui' |
| 20 | import { Admonition } from 'ui-patterns/admonition' |
| 21 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 22 | |
| 23 | import { CreateProjectForm } from './ProjectCreation.schema' |
| 24 | import { getAvailableRegions } from './ProjectCreation.utils' |
| 25 | import AlertError from '@/components/ui/AlertError' |
| 26 | import { InlineLink } from '@/components/ui/InlineLink' |
| 27 | import Panel from '@/components/ui/Panel' |
| 28 | import { useDefaultRegionQuery } from '@/data/misc/get-default-region-query' |
| 29 | import { useOrganizationAvailableRegionsQuery } from '@/data/organizations/organization-available-regions-query' |
| 30 | import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' |
| 31 | import type { DesiredInstanceSize } from '@/data/projects/new-project.constants' |
| 32 | import { BASE_PATH, PROVIDERS } from '@/lib/constants' |
| 33 | |
| 34 | interface RegionSelectorProps { |
| 35 | form: UseFormReturn<CreateProjectForm> |
| 36 | instanceSize?: DesiredInstanceSize |
| 37 | layout?: 'vertical' | 'horizontal' |
| 38 | } |
| 39 | |
| 40 | // [Joshen] Let's use a library to maintain the flag SVGs in the future |
| 41 | // I tried using https://flagpack.xyz/docs/development/react/ but couldn't get it to render |
| 42 | // ^ can try again next time |
| 43 | |
| 44 | // Maps smart region group codes to the specific-region code prefixes they contain. |
| 45 | // Used to check whether an incident affecting specific regions also affects a smart region selection. |
| 46 | const SMART_REGION_PREFIXES: Record<string, Array<string>> = { |
| 47 | americas: ['us-', 'ca-', 'sa-'], |
| 48 | emea: ['eu-', 'me-', 'af-'], |
| 49 | apac: ['ap-'], |
| 50 | } |
| 51 | |
| 52 | function smartRegionMatchesSpecific(smartCode: string, specificCode: string): boolean { |
| 53 | return (SMART_REGION_PREFIXES[smartCode] ?? []).some((prefix) => specificCode.startsWith(prefix)) |
| 54 | } |
| 55 | |
| 56 | // Map backend region names to user-friendly display names |
| 57 | const getDisplayNameForSmartRegion = (name: string): string => { |
| 58 | if (name === 'APAC') { |
| 59 | return 'Asia-Pacific' |
| 60 | } |
| 61 | return name |
| 62 | } |
| 63 | |
| 64 | export const RegionSelector = ({ |
| 65 | form, |
| 66 | instanceSize, |
| 67 | layout = 'horizontal', |
| 68 | }: RegionSelectorProps) => { |
| 69 | const { slug } = useParams() |
| 70 | const cloudProvider = form.getValues('cloudProvider') as CloudProvider |
| 71 | |
| 72 | const smartRegionEnabled = useFlag('enableSmartRegion') |
| 73 | |
| 74 | const { data: statusData } = useIncidentStatusQuery() |
| 75 | const { incidents = [] } = statusData ?? {} |
| 76 | |
| 77 | const { isPending: isLoadingDefaultRegion } = useDefaultRegionQuery( |
| 78 | { cloudProvider }, |
| 79 | { enabled: !smartRegionEnabled } |
| 80 | ) |
| 81 | |
| 82 | const { |
| 83 | data: availableRegionsData, |
| 84 | isPending: isLoadingAvailableRegions, |
| 85 | isError: isErrorAvailableRegions, |
| 86 | error: errorAvailableRegions, |
| 87 | } = useOrganizationAvailableRegionsQuery( |
| 88 | { slug, cloudProvider, desiredInstanceSize: instanceSize }, |
| 89 | { enabled: smartRegionEnabled, staleTime: 1000 * 60 * 5 } // 5 minutes |
| 90 | ) |
| 91 | |
| 92 | const smartRegions = availableRegionsData?.all.smartGroup ?? [] |
| 93 | const allRegions = availableRegionsData?.all.specific ?? [] |
| 94 | |
| 95 | const recommendedSmartRegions = new Set( |
| 96 | [availableRegionsData?.recommendations.smartGroup.code].filter(Boolean) |
| 97 | ) |
| 98 | const recommendedSpecificRegions = new Set( |
| 99 | availableRegionsData?.recommendations.specific.map((region) => region.code) |
| 100 | ) |
| 101 | |
| 102 | const availableRegions = getAvailableRegions(PROVIDERS[cloudProvider].id) |
| 103 | const regionsArray = Object.entries(availableRegions).map(([_key, value]) => { |
| 104 | return { |
| 105 | code: value.code, |
| 106 | name: value.displayName, |
| 107 | provider: cloudProvider, |
| 108 | status: undefined, |
| 109 | } |
| 110 | }) |
| 111 | |
| 112 | const regionOptions = smartRegionEnabled ? allRegions : regionsArray |
| 113 | const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion |
| 114 | |
| 115 | const showNonProdFields = |
| 116 | process.env.NEXT_PUBLIC_ENVIRONMENT === 'local' || |
| 117 | process.env.NEXT_PUBLIC_ENVIRONMENT === 'staging' |
| 118 | |
| 119 | const allSelectableRegions = [...smartRegions, ...regionOptions] |
| 120 | |
| 121 | if (isErrorAvailableRegions) { |
| 122 | return <AlertError subject="Error loading available regions" error={errorAvailableRegions} /> |
| 123 | } |
| 124 | |
| 125 | return ( |
| 126 | <Panel.Content> |
| 127 | <FormField |
| 128 | control={form.control} |
| 129 | name="dbRegion" |
| 130 | render={({ field }) => { |
| 131 | const selectedRegion = allSelectableRegions.find((region) => { |
| 132 | return !!region.name && region.name === field.value |
| 133 | }) |
| 134 | |
| 135 | const affectingIncidents = incidents.filter((incident) => { |
| 136 | const affectedRegions = incident.cache?.affected_regions ?? [] |
| 137 | if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false |
| 138 | |
| 139 | // Specific region: direct code match |
| 140 | if (affectedRegions.includes(selectedRegion.code)) return true |
| 141 | |
| 142 | // Smart region: match if any affected region falls within the smart group |
| 143 | return affectedRegions.some((specificCode) => |
| 144 | smartRegionMatchesSpecific(selectedRegion.code, specificCode) |
| 145 | ) |
| 146 | }) |
| 147 | |
| 148 | return ( |
| 149 | <> |
| 150 | <FormItemLayout |
| 151 | layout={layout} |
| 152 | label="Region" |
| 153 | description={ |
| 154 | <> |
| 155 | <p>Select the region closest to your users for the best performance.</p> |
| 156 | {showNonProdFields && ( |
| 157 | <div className="mt-2 text-warning"> |
| 158 | <p>Only these regions are supported for local/staging projects:</p> |
| 159 | <ul className="list-disc list-inside mt-1"> |
| 160 | <li>East US (North Virginia)</li> |
| 161 | <li>Central EU (Frankfurt)</li> |
| 162 | <li>Southeast Asia (Singapore)</li> |
| 163 | </ul> |
| 164 | </div> |
| 165 | )} |
| 166 | </> |
| 167 | } |
| 168 | > |
| 169 | <Select value={field.value} onValueChange={field.onChange} disabled={isLoading}> |
| 170 | <SelectTrigger className="[&>:nth-child(1)]:w-full [&>:nth-child(1)]:flex [&>:nth-child(1)]:items-start"> |
| 171 | <SelectValue |
| 172 | placeholder={ |
| 173 | isLoading |
| 174 | ? 'Loading available regions...' |
| 175 | : 'Select a region for your project..' |
| 176 | } |
| 177 | > |
| 178 | {field.value !== undefined && ( |
| 179 | <div className="flex items-center gap-x-3"> |
| 180 | {selectedRegion?.code && ( |
| 181 | <img |
| 182 | alt="region icon" |
| 183 | className="w-5 rounded-xs" |
| 184 | src={`${BASE_PATH}/img/regions/${selectedRegion.code}.svg`} |
| 185 | /> |
| 186 | )} |
| 187 | <span className="text-foreground"> |
| 188 | {selectedRegion?.name |
| 189 | ? getDisplayNameForSmartRegion(selectedRegion.name) |
| 190 | : field.value} |
| 191 | </span> |
| 192 | </div> |
| 193 | )} |
| 194 | </SelectValue> |
| 195 | </SelectTrigger> |
| 196 | <SelectContent> |
| 197 | {smartRegionEnabled && ( |
| 198 | <> |
| 199 | <SelectGroup> |
| 200 | <SelectLabel>General regions</SelectLabel> |
| 201 | {smartRegions.map((value) => { |
| 202 | return ( |
| 203 | <SelectItem |
| 204 | key={value.code} |
| 205 | value={value.name} |
| 206 | className="w-full [&>:nth-child(2)]:w-full" |
| 207 | > |
| 208 | <div className="flex flex-row items-center justify-between w-full"> |
| 209 | <div className="flex items-center gap-x-3"> |
| 210 | <img |
| 211 | alt="region icon" |
| 212 | className="w-5 rounded-xs" |
| 213 | src={`${BASE_PATH}/img/regions/${value.code}.svg`} |
| 214 | /> |
| 215 | <span className="text-foreground"> |
| 216 | {getDisplayNameForSmartRegion(value.name)} |
| 217 | </span> |
| 218 | </div> |
| 219 | |
| 220 | <div> |
| 221 | {recommendedSmartRegions.has(value.code) && ( |
| 222 | <Badge variant="success" className="mr-1"> |
| 223 | Recommended |
| 224 | </Badge> |
| 225 | )} |
| 226 | </div> |
| 227 | </div> |
| 228 | </SelectItem> |
| 229 | ) |
| 230 | })} |
| 231 | </SelectGroup> |
| 232 | <SelectSeparator /> |
| 233 | </> |
| 234 | )} |
| 235 | |
| 236 | <SelectGroup> |
| 237 | <SelectLabel>Specific regions</SelectLabel> |
| 238 | {regionOptions.map((value) => { |
| 239 | return ( |
| 240 | <SelectItem |
| 241 | key={value.code} |
| 242 | value={value.name} |
| 243 | className={cn( |
| 244 | 'w-full [&>:nth-child(2)]:w-full', |
| 245 | value.status !== undefined && 'pointer-events-auto!' |
| 246 | )} |
| 247 | disabled={value.status !== undefined} |
| 248 | > |
| 249 | <div className="flex flex-row items-center justify-between w-full gap-x-2"> |
| 250 | <div className="flex items-center gap-x-3"> |
| 251 | <img |
| 252 | alt="region icon" |
| 253 | className="w-5 rounded-xs" |
| 254 | src={`${BASE_PATH}/img/regions/${value.code}.svg`} |
| 255 | /> |
| 256 | <div className="flex items-center gap-x-2"> |
| 257 | <span className="text-foreground">{value.name}</span> |
| 258 | <span className="text-xs text-foreground-lighter font-mono"> |
| 259 | {value.code} |
| 260 | </span> |
| 261 | </div> |
| 262 | </div> |
| 263 | |
| 264 | {recommendedSpecificRegions.has(value.code) && ( |
| 265 | <Badge variant="success" className="mr-1"> |
| 266 | Recommended |
| 267 | </Badge> |
| 268 | )} |
| 269 | {value.status !== undefined && value.status === 'capacity' && ( |
| 270 | <Tooltip> |
| 271 | <TooltipTrigger> |
| 272 | <Badge variant="warning" className="mr-1"> |
| 273 | Unavailable |
| 274 | </Badge> |
| 275 | </TooltipTrigger> |
| 276 | <TooltipContent> |
| 277 | Temporarily unavailable due to this region being at capacity. |
| 278 | </TooltipContent> |
| 279 | </Tooltip> |
| 280 | )} |
| 281 | </div> |
| 282 | </SelectItem> |
| 283 | ) |
| 284 | })} |
| 285 | </SelectGroup> |
| 286 | </SelectContent> |
| 287 | </Select> |
| 288 | </FormItemLayout> |
| 289 | |
| 290 | {affectingIncidents.length > 0 && ( |
| 291 | <FormItemLayout layout="horizontal"> |
| 292 | <Admonition |
| 293 | type="warning" |
| 294 | title="Incident in progress for this region" |
| 295 | description={ |
| 296 | <> |
| 297 | We're currently investigating an issue that may impact projects in this |
| 298 | region. Follow updates on{' '} |
| 299 | <InlineLink href="https://status.supabase.com"> |
| 300 | status.supabase.com |
| 301 | </InlineLink> |
| 302 | . |
| 303 | </> |
| 304 | } |
| 305 | className="mt-3" |
| 306 | /> |
| 307 | </FormItemLayout> |
| 308 | )} |
| 309 | </> |
| 310 | ) |
| 311 | }} |
| 312 | /> |
| 313 | </Panel.Content> |
| 314 | ) |
| 315 | } |