diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx new file mode 100644 index 0000000000000..244222531a937 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { PublicationsAvailability } from './PublicationsAvailability' + +const { mockUseHighAvailability } = vi.hoisted(() => ({ + mockUseHighAvailability: vi.fn(), +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +describe('PublicationsAvailability', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('shows the disabled empty state instead of page content for High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true }) + + render( + +
Publications content
+
+ ) + + expect( + screen.getByText('Publications unavailable on High Availability projects') + ).toBeInTheDocument() + expect( + screen.getByText( + "We're working to bring publications to High Availability projects. Contact support if this is blocking your work." + ) + ).toBeInTheDocument() + expect(screen.queryByText('Publications content')).not.toBeInTheDocument() + }) + + it('renders page content for non-High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false }) + + render( + +
Publications content
+
+ ) + + expect(screen.getByText('Publications content')).toBeInTheDocument() + expect( + screen.queryByText('Publications unavailable on High Availability projects') + ).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx new file mode 100644 index 0000000000000..d38156ada7144 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx @@ -0,0 +1,23 @@ +import { BookOpen } from 'lucide-react' +import type { PropsWithChildren } from 'react' + +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' + +export const PublicationsAvailability = ({ children }: PropsWithChildren) => { + const { isHighAvailability } = useHighAvailability() + + if (isHighAvailability) { + return ( +
+ +
+ ) + } + + return children +} diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx new file mode 100644 index 0000000000000..9718945acbcdb --- /dev/null +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx @@ -0,0 +1,90 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Usage } from './Usage' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUseAsyncCheckPermissions, + mockUseOrgDailyStatsQuery, + mockUseOrgSubscriptionQuery, + mockUseProjectDetailQuery, +} = vi.hoisted(() => ({ + mockUseAsyncCheckPermissions: vi.fn(), + mockUseOrgDailyStatsQuery: vi.fn(), + mockUseOrgSubscriptionQuery: vi.fn(), + mockUseProjectDetailQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ slug: 'test-org' }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/data/analytics/org-daily-stats-query', async (importOriginal) => ({ + ...(await importOriginal()), + useOrgDailyStatsQuery: mockUseOrgDailyStatsQuery, +})) + +vi.mock('@/data/projects/project-detail-query', () => ({ + useProjectDetailQuery: mockUseProjectDetailQuery, +})) + +vi.mock('@/data/subscriptions/org-subscription-query', () => ({ + useOrgSubscriptionQuery: mockUseOrgSubscriptionQuery, +})) + +describe('Usage', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isLoading: false }) + mockUseProjectDetailQuery.mockReturnValue({ + data: { + ref: 'ha-project', + name: 'HA project', + high_availability: true, + }, + isPending: false, + }) + mockUseOrgSubscriptionQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + isSuccess: false, + }) + mockUseOrgDailyStatsQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + }) + }) + + it('shows a coming-soon empty state for a High Availability project', () => { + customRender(, { + nuqs: { searchParams: { projectRef: 'ha-project' } }, + }) + + expect(screen.getByText('Usage unavailable on High Availability projects')).toBeInTheDocument() + expect( + screen.getByText('Usage insights for High Availability projects are coming soon.') + ).toBeInTheDocument() + expect(screen.queryByText('Usage filtered by project')).not.toBeInTheDocument() + + expect(mockUseProjectDetailQuery).toHaveBeenCalledWith({ ref: 'ha-project' }) + expect(mockUseOrgSubscriptionQuery).toHaveBeenCalledWith( + { orgSlug: 'test-org' }, + { enabled: false } + ) + expect(mockUseOrgDailyStatsQuery).toHaveBeenCalledWith( + expect.objectContaining({ orgSlug: 'test-org', projectRef: 'ha-project' }), + { enabled: false } + ) + }) +}) diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx index 436a2894f77f2..90138f2caeca7 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import dayjs from 'dayjs' -import { Check, ChevronDown } from 'lucide-react' +import { ChartArea, Check, ChevronDown } from 'lucide-react' import Link from 'next/link' import { useQueryState } from 'nuqs' import { useMemo, useState } from 'react' @@ -26,12 +26,14 @@ import { } from '@/components/layouts/Scaffold' import { AlertError } from '@/components/ui/AlertError' import { DateRangePicker } from '@/components/ui/DateRangePicker' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { NoPermission } from '@/components/ui/NoPermission' import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector' import { useOrgDailyStatsQuery } from '@/data/analytics/org-daily-stats-query' import { useProjectDetailQuery } from '@/data/projects/project-detail-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { resolveHighAvailability } from '@/hooks/misc/useHighAvailability' import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics' export const Usage = () => { @@ -42,6 +44,12 @@ export const Usage = () => { const [selectedProjectRef, setSelectedProjectRef] = useQueryState('projectRef') const [openProjectSelector, setOpenProjectSelector] = useState(false) + const { data: selectedProject, isPending: isLoadingSelectedProject } = useProjectDetailQuery({ + ref: selectedProjectRef ?? undefined, + }) + const isHighAvailability = resolveHighAvailability(selectedProject) + const canLoadUsage = !selectedProjectRef || (!isLoadingSelectedProject && !isHighAvailability) + const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' @@ -53,11 +61,7 @@ export const Usage = () => { isPending: isLoadingSubscription, isError: isErrorSubscription, isSuccess: isSuccessSubscription, - } = useOrgSubscriptionQuery({ orgSlug: slug }) - - const { data: selectedProject } = useProjectDetailQuery({ - ref: selectedProjectRef ?? undefined, - }) + } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canLoadUsage }) const billingCycleStart = useMemo(() => { return dayjs.unix(subscription?.current_period_start ?? 0).utc() @@ -107,12 +111,27 @@ export const Usage = () => { error: orgDailyStatsError, isPending: isLoadingOrgDailyStats, isError: isErrorOrgDailyStats, - } = useOrgDailyStatsQuery({ - orgSlug: slug, - projectRef: selectedProjectRef ?? undefined, - startDate, - endDate, - }) + } = useOrgDailyStatsQuery( + { + orgSlug: slug, + projectRef: selectedProjectRef ?? undefined, + startDate, + endDate, + }, + { enabled: canLoadUsage } + ) + + if (isHighAvailability) { + return ( +
+ +
+ ) + } return ( <> diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx index ebffc046a6c04..6b150756a5d91 100644 --- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx +++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx @@ -194,7 +194,7 @@ export const OrganizationInvite = () => { Accept invite diff --git a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx index 0b78c9f0378a8..5053016ed3981 100644 --- a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx +++ b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { useFlag, useParams } from 'common' import { useRouter } from 'next/router' -import { useMemo, useState, type ComponentPropsWithoutRef } from 'react' +import { useMemo, useRef, useState, type ComponentPropsWithoutRef } from 'react' import { useForm } from 'react-hook-form' import { AWS_REGIONS, CloudProvider } from 'shared-data' import { toast } from 'sonner' @@ -99,6 +99,7 @@ export const ResumeProjectButton = ({ mode: 'onChange', defaultValues: { postgresVersionSelection: '' }, }) + const lastValidPostgresVersionSelection = useRef('') const onSelectRestore = () => { if (project?.status !== PROJECT_STATUS.INACTIVE) { @@ -209,6 +210,7 @@ export const ResumeProjectButton = ({ dbRegion={region?.displayName ?? ''} cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider} organizationSlug={selectedOrganization?.slug} + lastValidSelectionRef={lastValidPostgresVersionSelection} /> )} /> diff --git a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx index fce289df2746d..93b8e7ad0f810 100644 --- a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx @@ -1,5 +1,7 @@ +import { useEffect, useRef } from 'react' import { UseFormReturn } from 'react-hook-form' -import { FormControl, FormField, Switch } from 'ui' +import { type CloudProvider } from 'shared-data' +import { Badge, FormControl, FormField, Switch, useWatch } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { CreateProjectForm } from './ProjectCreation.schema' @@ -8,10 +10,86 @@ import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' interface HighAvailabilityInputProps { form: UseFormReturn + // Derived from an org-wide regions query owned by the parent form, since fetching it + // here would mean duplicating that query's slug/cloud-provider/instance-size context. + highAvailabilityRegionName: string | undefined } -export const HighAvailabilityInput = ({ form }: HighAvailabilityInputProps) => { +export const HighAvailabilityInput = ({ + form, + highAvailabilityRegionName, +}: HighAvailabilityInputProps) => { + const { getValues, setValue } = form const { hasAccess } = useCheckEntitlements('instances.high_availability') + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + + // Fields to revert to when toggling off HA, so previously selected values aren't lost. + const beforeHighAvailability = useRef<{ + cloudProvider: CloudProvider | undefined + postgresVersionSelection: string | undefined + dbRegion: string | null + }>({ + cloudProvider: undefined, + postgresVersionSelection: undefined, + dbRegion: null, + }) + + const handleHighAvailabilityChange = (checked: boolean) => { + if (checked) { + const currentCloudProvider = getValues('cloudProvider') as CloudProvider + if (currentCloudProvider !== 'AWS_K8S') { + beforeHighAvailability.current.cloudProvider = currentCloudProvider + setValue('cloudProvider', 'AWS_K8S') + } + + beforeHighAvailability.current.postgresVersionSelection = getValues( + 'postgresVersionSelection' + ) + setValue('useOrioleDb', false) + + const currentRegion = getValues('dbRegion') + if ( + highAvailabilityRegionName !== undefined && + currentRegion !== highAvailabilityRegionName + ) { + beforeHighAvailability.current.dbRegion = currentRegion ?? null + setValue('dbRegion', highAvailabilityRegionName) + } + } else { + if (beforeHighAvailability.current.cloudProvider !== undefined) { + setValue('cloudProvider', beforeHighAvailability.current.cloudProvider) + beforeHighAvailability.current.cloudProvider = undefined + } + + if (beforeHighAvailability.current.postgresVersionSelection !== undefined) { + setValue( + 'postgresVersionSelection', + beforeHighAvailability.current.postgresVersionSelection + ) + beforeHighAvailability.current.postgresVersionSelection = undefined + } + + if (beforeHighAvailability.current.dbRegion !== null) { + setValue('dbRegion', beforeHighAvailability.current.dbRegion) + beforeHighAvailability.current.dbRegion = null + } + } + } + + // Catches the case where highAvailabilityRegionName wasn't loaded yet at the moment the + // toggle fired above (the org's available-regions query for AWS_K8S may still be in + // flight). The region auto-fill effect in the parent form skips dirty fields, so a + // manually chosen region would otherwise keep showing in the trigger — force it over + // explicitly once the HA region becomes known. + useEffect(() => { + if (!highAvailability || highAvailabilityRegionName === undefined) return + const currentRegion = getValues('dbRegion') + if (currentRegion === highAvailabilityRegionName) return + if (beforeHighAvailability.current.dbRegion === null) { + beforeHighAvailability.current.dbRegion = currentRegion ?? null + } + setValue('dbRegion', highAvailabilityRegionName) + }, [highAvailability, highAvailabilityRegionName, getValues, setValue]) if (!hasAccess) return null @@ -22,12 +100,24 @@ export const HighAvailabilityInput = ({ form }: HighAvailabilityInputProps) => { name="highAvailability" render={({ field }) => ( + High availability + Alpha + + } + description="Horizontally scalable Postgres for highly available deployments. Free during Alpha for up to 2 projects." layout="horizontal" > - + { + handleHighAvailabilityChange(checked) + field.onChange(checked) + }} + /> )} diff --git a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx index e1c62c30d4d2b..3779c2912f7a7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx @@ -1,7 +1,8 @@ import { useParams } from 'common' +import { useRef } from 'react' import { UseFormReturn } from 'react-hook-form' import { type CloudProvider } from 'shared-data' -import { FormControl, FormField, Input } from 'ui' +import { FormControl, FormField, Input, useWatch } from 'ui' import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' @@ -17,6 +18,10 @@ interface InternalOnlyConfigurationProps { export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationProps) => { const { slug } = useParams() const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + // Held here (outside the collapsible content) so the selector's last valid + // selection survives the section being collapsed and reopened. + const lastValidPostgresVersionSelection = useRef('') return ( @@ -36,6 +41,8 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro cloudProvider={form.getValues('cloudProvider') as CloudProvider} organizationSlug={slug} dbRegion={form.getValues('dbRegion')} + disabled={highAvailability} + lastValidSelectionRef={lastValidPostgresVersionSelection} /> )} /> @@ -49,21 +56,23 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro
- ( - - - - - - )} - /> + {!highAvailability && ( + ( + + + + + + )} + /> + )} form: UseFormReturn + /** + * Owned by the form owner (not this component) so the last valid selection + * survives this selector unmounting, e.g. when its collapsible section + * closes. Create it with useRef('') alongside the form. + */ + lastValidSelectionRef: { current: string } type?: 'create' | 'unpause' layout?: 'vertical' | 'horizontal' label?: string @@ -56,9 +63,11 @@ export const extractPostgresVersionDetails = (value: string): PostgresVersionDet export const PostgresVersionSelector = ({ cloudProvider, dbRegion, + disabled = false, organizationSlug, field, form, + lastValidSelectionRef, type = 'create', layout = 'horizontal', label = 'Postgres version', @@ -96,13 +105,32 @@ export const PostgresVersionSelector = ({ name: 'postgresVersionSelection', }) + // react-hook-form intermittently drops this field's value when its Controller + // remounts, so a one-shot "set the default once versions load" effect leaves + // the select stuck empty. Instead this effect re-asserts off the watched value: + // a selection present in the current list is kept (and remembered in the + // owner-held lastValidSelectionRef), and when the value is missing or cleared + // out from under us it restores the last valid selection, falling back to the + // GA default. useEffect(() => { - if (availableVersions.length > 0) { - const gaVersion = availableVersions.find((x) => x.release_channel === 'ga') - const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0]) - form.setValue('postgresVersionSelection', defaultValue) + if (availableVersions.length === 0) return + const isSelectionAvailable = (selection: string) => + availableVersions.some((version) => formatValue(version) === selection) + + if (postgresVersionSelection && isSelectionAvailable(postgresVersionSelection)) { + lastValidSelectionRef.current = postgresVersionSelection + return + } + + if (isSelectionAvailable(lastValidSelectionRef.current)) { + form.setValue('postgresVersionSelection', lastValidSelectionRef.current) + return } - }, [isSuccess, availableVersions, form]) + + const gaVersion = availableVersions.find((x) => x.release_channel === 'ga') + const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0]) + form.setValue('postgresVersionSelection', defaultValue) + }, [isSuccess, availableVersions, postgresVersionSelection, lastValidSelectionRef, form]) return ( @@ -110,6 +138,7 @@ export const PostgresVersionSelector = ({ value={postgresVersionSelection} onValueChange={field.onChange} disabled={ + disabled || availableVersions.length === 0 || (type === 'create' && isLoadingProjectCreateVersions) || (type === 'unpause' && isLoadingProjectUnpauseVersions) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts index 2cdb36b8c86ba..fb6dcd1d258a7 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts @@ -1,4 +1,11 @@ -import { DesiredInstanceSize } from '@/data/projects/new-project.constants' +import type { + DesiredInstanceSize, + PostgresEngine, + ReleaseChannel, +} from '@/data/projects/new-project.constants' + +export const HIGH_AVAILABILITY_POSTGRES_ENGINE = '17' satisfies PostgresEngine +export const HIGH_AVAILABILITY_RELEASE_CHANNEL = 'ga' satisfies ReleaseChannel // [Joshen] Obtained from https://gist.github.com/tadast/8827699 export const COUNTRY_LAT_LON = { diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts new file mode 100644 index 0000000000000..cb820643b13b0 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' + +import { + HIGH_AVAILABILITY_POSTGRES_ENGINE, + HIGH_AVAILABILITY_RELEASE_CHANNEL, +} from './ProjectCreation.constants' +import { + filterHighAvailabilityRegions, + getHighAvailabilityRegionCode, +} from './ProjectCreation.utils' + +describe('High Availability project creation constraints', () => { + it('pins the Alpha Postgres engine and release channel', () => { + expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17') + expect(HIGH_AVAILABILITY_RELEASE_CHANNEL).toBe('ga') + }) + + it.each([ + ['local', 'eu-central-1'], + ['staging', 'us-east-1'], + ['prod', undefined], + ])('resolves the %s region restriction', (environment, expectedRegion) => { + expect(getHighAvailabilityRegionCode(environment)).toBe(expectedRegion) + }) + + it.each([ + ['local', 'eu-central-1'], + ['staging', 'us-east-1'], + ['prod', undefined], + ])('limits %s projects to the required region', (environment, expectedRegion) => { + const regions = [{ code: 'us-east-1' }, { code: 'eu-central-1' }] + + expect(filterHighAvailabilityRegions(regions, true, environment)).toEqual( + expectedRegion === undefined ? regions : [{ code: expectedRegion }] + ) + expect(filterHighAvailabilityRegions(regions, false, environment)).toEqual(regions) + }) +}) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index 3b399505d9ef2..c77d48d680b02 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -45,3 +45,22 @@ export const monthlyInstancePrice = (instance: string | undefined): number => { export const instanceLabel = (instance: string | undefined): string => { return instanceSizeSpecs[instance as DesiredInstanceSize]?.label || 'Micro' } + +export const getHighAvailabilityRegionCode = ( + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +) => { + if (environment === 'local') return 'eu-central-1' + if (environment === 'staging') return 'us-east-1' + return undefined +} + +export const filterHighAvailabilityRegions = ( + regions: T[], + highAvailability: boolean, + environment = process.env.NEXT_PUBLIC_ENVIRONMENT +) => { + const regionCode = getHighAvailabilityRegionCode(environment) + return highAvailability && regionCode !== undefined + ? regions.filter((region) => region.code === regionCode) + : regions +} diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx index 897ba9b4c8e67..4aee33481c3a9 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx @@ -23,9 +23,14 @@ import { HighAvailabilityInput } from './HighAvailabilityInput' import { InternalOnlyConfiguration } from './InternalOnlyConfiguration' import { OrganizationSelector } from './OrganizationSelector' import { extractPostgresVersionDetails } from './PostgresVersionSelector' -import { sizes } from './ProjectCreation.constants' +import { + HIGH_AVAILABILITY_POSTGRES_ENGINE, + HIGH_AVAILABILITY_RELEASE_CHANNEL, + sizes, +} from './ProjectCreation.constants' import { FormSchema } from './ProjectCreation.schema' import { + getHighAvailabilityRegionCode, instanceLabel, monthlyInstancePrice, smartRegionToExactRegion, @@ -185,6 +190,7 @@ export const ProjectCreationForm = ({ const { dirtyFields } = useFormState(form) const isDbRegionDirty = dirtyFields.dbRegion const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' + const highAvailabilityRegionCode = getHighAvailabilityRegionCode() // Read dirty state during render rather than depending on form.formState in the // effect — form.formState is a Proxy that gets a new reference every render, which @@ -265,15 +271,24 @@ export const ProjectCreationForm = ({ } ) + const highAvailabilityRegion = + highAvailability && highAvailabilityRegionCode !== undefined + ? availableRegionsData?.all.specific.find( + (region) => region.code === highAvailabilityRegionCode + ) + : undefined const recommendedSmartRegion = smartRegionEnabled ? availableRegionsData?.recommendations.smartGroup.name : '' const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError - const defaultRegion = smartRegionEnabled - ? availableRegionsData?.recommendations.smartGroup.name - : (autoDefaultRegion ?? fixedDefaultRegion) + const defaultRegion = + highAvailability && highAvailabilityRegionCode !== undefined + ? highAvailabilityRegion?.name + : smartRegionEnabled + ? recommendedSmartRegion + : (autoDefaultRegion ?? fixedDefaultRegion) const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices const canConfigureGitHubOnCreate = @@ -381,7 +396,11 @@ export const ProjectCreationForm = ({ shouldRunMigrations, } = values - if (postgresVersion && !postgresVersion.match(/1[2-9]\..*/)) { + // HA projects never take a custom version — the API resolves the image from + // postgresEngine + releaseChannel. + const customPostgresVersion = highAvailability ? undefined : postgresVersion + + if (customPostgresVersion && !customPostgresVersion.match(/1[2-9]\..*/)) { return toast.error( `Invalid Postgres version, should start with a number between 12-19, a dot and additional characters, i.e. 15.2 or 15.2.0-3` ) @@ -402,9 +421,19 @@ export const ProjectCreationForm = ({ extractPostgresVersionDetails(postgresVersionSelection) const { smartGroup = [], specific = [] } = availableRegionsData?.all ?? {} - const selectedRegion = smartRegionEnabled - ? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion)) - : undefined + const selectedRegion = + highAvailability && highAvailabilityRegionCode !== undefined + ? specific.find((region) => region.code === highAvailabilityRegionCode) + : smartRegionEnabled + ? (smartGroup.find((x) => x.name === dbRegion) ?? + specific.find((x) => x.name === dbRegion)) + : undefined + + if (highAvailability && highAvailabilityRegionCode !== undefined && !selectedRegion) { + return toast.error( + `High Availability projects are not available in the required region (${highAvailabilityRegionCode})` + ) + } const parsedGitHubRepositoryId = githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined const shouldIncludeGitHubFields = @@ -449,8 +478,16 @@ export const ProjectCreationForm = ({ dataApiExposedSchemas: !dataApi ? [] : undefined, dataApiUseApiSchema: false, dataApiRevokeDefaultPrivileges: dataApi && !dataApiDefaultPrivileges, - postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine, - releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel, + postgresEngine: highAvailability + ? HIGH_AVAILABILITY_POSTGRES_ENGINE + : useOrioleDb + ? availableOrioleVersion?.postgres_engine + : postgresEngine, + releaseChannel: highAvailability + ? HIGH_AVAILABILITY_RELEASE_CHANNEL + : useOrioleDb + ? availableOrioleVersion?.release_channel + : releaseChannel, ...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }), ...(shouldIncludeGitHubFields ? { @@ -460,11 +497,11 @@ export const ProjectCreationForm = ({ : {}), } - if (postgresVersion || instanceType) { + if (customPostgresVersion || instanceType) { data['customSupabaseRequest'] = { ami: { - ...(postgresVersion && { - search_tags: { 'tag:postgresVersion': postgresVersion }, + ...(customPostgresVersion && { + search_tags: { 'tag:postgresVersion': customPostgresVersion }, }), ...(instanceType && { instance_type: instanceType }), }, @@ -510,24 +547,12 @@ export const ProjectCreationForm = ({ } }, [defaultRegion, isDbRegionDirty, setValue]) - useEffect(() => { - if (!isDbRegionDirty && recommendedSmartRegion) { - setValue('dbRegion', recommendedSmartRegion) - } - }, [recommendedSmartRegion, isDbRegionDirty, setValue]) - useEffect(() => { if (regionError && fixedDefaultRegion) { resetField('dbRegion', { defaultValue: fixedDefaultRegion }) } }, [regionError, resetField, fixedDefaultRegion]) - useEffect(() => { - if (highAvailability && cloudProvider !== 'AWS_K8S') { - setValue('cloudProvider', 'AWS_K8S') - } - }, [highAvailability, cloudProvider, setValue]) - useEffect(() => { if (watchedInstanceSize !== instanceSize) { setValue('instanceSize', instanceSize, { @@ -657,9 +682,12 @@ export const ProjectCreationForm = ({ )} - {canChooseInstanceSize && } + - + {canChooseInstanceSize && } @@ -674,9 +702,9 @@ export const ProjectCreationForm = ({ {showInternalOnlyConfiguration && } - {showAdvancedConfig && !!availableOrioleVersion && ( - - )} + {showAdvancedConfig && + !!availableOrioleVersion && + highAvailability !== true && } {shouldShowFreeProjectInfo ? ( { const { slug } = useParams() const cloudProvider = form.getValues('cloudProvider') as CloudProvider + const highAvailability = useWatch({ control: form.control, name: 'highAvailability' }) + const dbRegion = useWatch({ control: form.control, name: 'dbRegion' }) + const highAvailabilityRegionCode = getHighAvailabilityRegionCode() const { hasLoaded: flagsLoaded } = useFeatureFlags() const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS' @@ -91,8 +101,11 @@ export const RegionSelector = ({ { enabled: smartRegionEnabled, staleTime: 1000 * 60 * 5 } // 5 minutes ) - const smartRegions = availableRegionsData?.all.smartGroup ?? [] + const allSmartRegions = availableRegionsData?.all.smartGroup ?? [] const allRegions = availableRegionsData?.all.specific ?? [] + const restrictHighAvailabilityRegion = + highAvailability && highAvailabilityRegionCode !== undefined + const smartRegions = highAvailability ? [] : allSmartRegions const recommendedSmartRegions = new Set( [availableRegionsData?.recommendations.smartGroup.code].filter(Boolean) @@ -111,7 +124,11 @@ export const RegionSelector = ({ } }) - const regionOptions = smartRegionEnabled ? allRegions : regionsArray + const unfilteredRegionOptions = smartRegionEnabled ? allRegions : regionsArray + const regionOptions = filterHighAvailabilityRegions( + [...unfilteredRegionOptions], + highAvailability + ) const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion const showNonProdFields = @@ -120,6 +137,32 @@ export const RegionSelector = ({ const allSelectableRegions = [...smartRegions, ...regionOptions] + // react-hook-form intermittently drops this field's value when its Controller + // remounts (e.g. a sibling section mounting/unmounting in the same update, such as + // toggling high availability), so a one-shot effect isn't enough. Instead this effect + // re-asserts off the watched value: a region present in the current list is kept (and + // remembered in lastValidRegionRef), and when it's missing or cleared out from under us + // it restores the last valid region. allSelectableRegions is intentionally omitted from + // deps — it's a new array every render, and comparing it by reference would defeat the + // point of reacting to genuine content changes on every render where they occur. + const lastValidRegionRef = useRef(undefined) + useEffect(() => { + if (allSelectableRegions.length === 0) return + const isRegionAvailable = (name: string | undefined) => + !!name && allSelectableRegions.some((region) => region.name === name) + + if (isRegionAvailable(dbRegion)) { + lastValidRegionRef.current = dbRegion + return + } + + const lastValidRegion = lastValidRegionRef.current + if (lastValidRegion !== undefined && isRegionAvailable(lastValidRegion)) { + form.setValue('dbRegion', lastValidRegion) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dbRegion, form]) + if (isErrorAvailableRegions) { return } @@ -131,9 +174,16 @@ export const RegionSelector = ({ name="dbRegion" render={({ field }) => { const selectedRegion = allSelectableRegions.find((region) => { - return !!region.name && region.name === field.value + return !!region.name && region.name === dbRegion }) + const selectedRegionLabel = selectedRegion?.name + ? getDisplayNameForSmartRegion(selectedRegion.name) + : dbRegion + const triggerLabel = isLoadingAvailableRegions + ? 'Loading available regions...' + : selectedRegionLabel + const affectingIncidents = incidents.filter((incident) => { const affectedRegions = incident.cache?.affected_regions ?? [] if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false @@ -156,21 +206,28 @@ export const RegionSelector = ({ description={ <>

Select the region closest to your users for the best performance.

- {showNonProdFields && ( + {restrictHighAvailabilityRegion ? (
-

Only these regions are supported for local/staging projects:

-
    -
  • East US (North Virginia)
  • -
  • Central EU (Frankfurt)
  • -
  • Southeast Asia (Singapore)
  • -
+ High Availability projects are currently limited to{' '} + {regionOptions[0]?.name ?? highAvailabilityRegionCode}.
+ ) : ( + showNonProdFields && ( +
+

Only these regions are supported for local/staging projects:

+
    +
  • East US (North Virginia)
  • +
  • Central EU (Frankfurt)
  • +
  • Southeast Asia (Singapore)
  • +
+
+ ) )} } > - - {field.value !== undefined && ( + {dbRegion !== undefined && (
+ {isLoadingAvailableRegions && ( + + )} {selectedRegion?.code && ( // For some reason, Safari considered the empty string alt text on this icon as misspelled (with VoiceOver) // Only way to fix it is to set the role. Not needed for the combobox options @@ -194,17 +254,13 @@ export const RegionSelector = ({ src={`${BASE_PATH}/img/regions/${selectedRegion.code}.svg`} /> )} - - {selectedRegion?.name - ? getDisplayNameForSmartRegion(selectedRegion.name) - : field.value} - + {triggerLabel}
)}
- {smartRegionEnabled && ( + {smartRegionEnabled && !highAvailability && ( <> General regions @@ -244,7 +300,9 @@ export const RegionSelector = ({ )} - Specific regions + + {highAvailability ? 'High Availability Regions' : 'Specific regions'} + {regionOptions.map((value) => { return ( { + it('shows branches as unavailable for HA projects without cached branch data', () => { + render( + + ) + + expect(screen.getByText('Unavailable')).toBeInTheDocument() + expect(screen.queryByText('No branches')).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx index 220d9d7106301..cc68eb0a6bd7e 100644 --- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx @@ -8,28 +8,91 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { HighAvailabilityBadge } from './HighAvailabilityBadge' import { ServiceStatus } from './ServiceStatus' import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper' +import { DisableInteraction } from '@/components/ui/DisableInteraction' import { SingleStat } from '@/components/ui/SingleStat' import { useBranchesQuery } from '@/data/branches/branches-query' import { useBackupsQuery } from '@/data/database/backups-query' import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query' import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { PROJECT_STATUS } from '@/lib/constants' import { EMPTY_ARR } from '@/lib/void' +interface BranchStatValueProps { + currentBranch?: { created_at?: string } + isDefaultProject: boolean + isError: boolean + isHighAvailability: boolean + isLoading: boolean + latestNonDefaultBranch?: { name?: string } +} + +export const BranchStatValue = ({ + currentBranch, + isDefaultProject, + isError, + isHighAvailability, + isLoading, + latestNonDefaultBranch, +}: BranchStatValueProps) => { + if (isHighAvailability) { + return

Unavailable

+ } + + if (isLoading) { + return + } + + if (isError) { + return

Unable to load

+ } + + if (isDefaultProject) { + return ( +

+ {latestNonDefaultBranch?.name ?? 'No branches'} +

+ ) + } + + if (currentBranch?.created_at) { + return ( + + ) + } + + return

Unknown

+} + export const ActivityStats = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const { data: organization } = useSelectedOrganizationQuery() const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug }) const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref) const parentProjectRef = project?.parent_project_ref ?? project?.ref - const { data: branchesData, isPending: isLoadingBranches } = useBranchesQuery({ - projectRef: parentProjectRef, - }) + const { + data: branchesData, + isPending: isLoadingBranches, + isError: isBranchesError, + } = useBranchesQuery( + { + projectRef: parentProjectRef, + }, + { enabled: !isHighAvailability } + ) const isDefaultProject = project?.parent_project_ref === undefined const currentBranch = useMemo( () => (branchesData ?? []).find((b) => b.project_ref === ref), @@ -132,38 +195,27 @@ export const ActivityStats = () => { } /> - } - label={{isDefaultProject ? 'Recent branch' : 'Branch Created'}} - trackingProperties={{ - stat_type: 'branches', - stat_value: branchesData?.length ?? 0, - }} - value={ - isLoadingBranches ? ( - - ) : isDefaultProject ? ( -

- {latestNonDefaultBranch?.name ?? 'No branches'} -

- ) : currentBranch?.created_at ? ( - + } + label={{isDefaultProject ? 'Recent branch' : 'Branch Created'}} + trackingProperties={{ + stat_type: 'branches', + stat_value: branchesData?.length ?? 0, + }} + value={ + - ) : ( -

Unknown

- ) - } - /> + } + /> + { + it('marks Realtime as disabled on High Availability projects', () => { + expect(resolveRealtimeServiceStatus(true, 'UNHEALTHY')).toBe('DISABLED') + }) + + it('preserves Realtime health on standard projects', () => { + expect(resolveRealtimeServiceStatus(false, 'ACTIVE_HEALTHY')).toBe('ACTIVE_HEALTHY') + expect(resolveRealtimeServiceStatus(false, 'UNHEALTHY')).toBe('UNHEALTHY') + }) +}) diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx index 369eb5d88f788..570778754e2e9 100644 --- a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx @@ -5,23 +5,22 @@ import Link from 'next/link' import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui' import { useUnifiedLogsPreview } from '../App/FeaturePreview/FeaturePreviewContext' +import { resolveRealtimeServiceStatus, type ProjectServiceStatus } from './ServiceStatus.utils' import { InlineLink } from '@/components/ui/InlineLink' import { SingleStat } from '@/components/ui/SingleStat' import { useBranchesQuery } from '@/data/branches/branches-query' import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query' import { useProjectServiceStatusQuery, - type ProjectServiceStatus as APIProjectServiceStatus, type ServiceHealthResponse, } from '@/data/service-status/service-status-query' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' const SERVICE_STATUS_THRESHOLD = 5 // minutes -type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED' - const iconProps = { size: 18, strokeWidth: 1.5, @@ -101,6 +100,7 @@ const extractDbSchema = (response: ServiceHealthResponse | undefined) => { export const ServiceStatus = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability } = useHighAvailability() const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview() const { @@ -136,6 +136,9 @@ export const ServiceStatus = () => { refetchInterval: (query) => { const data = query.state.data const isServiceUnhealthy = data?.some((service) => { + if (isHighAvailability && service.name === 'realtime') { + return false + } // if the postgrest service has an empty schema, postgrest has been disabled if (service.name === 'rest' && extractDbSchema(service) === '') { return false @@ -213,7 +216,7 @@ export const ServiceStatus = () => { error: realtimeStatus?.error, docsUrl: undefined, isLoading, - status: realtimeStatus?.status ?? 'UNHEALTHY', + status: resolveRealtimeServiceStatus(isHighAvailability, realtimeStatus?.status), logsUrl: isUnifiedLogsEnabled ? '/logs?filter=log_type:eq:realtime' : '/logs/realtime-logs', diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts new file mode 100644 index 0000000000000..9affbfb9f27a7 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts @@ -0,0 +1,10 @@ +import type { ProjectServiceStatus as APIProjectServiceStatus } from '@/data/service-status/service-status-query' + +export type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED' + +export const resolveRealtimeServiceStatus = ( + isHighAvailability: boolean, + status?: APIProjectServiceStatus +): ProjectServiceStatus => { + return isHighAvailability ? 'DISABLED' : (status ?? 'UNHEALTHY') +} diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx new file mode 100644 index 0000000000000..491aaf978fe7d --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx @@ -0,0 +1,154 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ConnectionPooling } from './ConnectionPooling' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUpdatePoolerConfig, + mockUseAsyncCheckPermissions, + mockUseCheckEntitlements, + mockUseHighAvailability, + mockUseMaxConnectionsQuery, + mockUsePgbouncerConfigQuery, + mockUsePgbouncerConfigurationUpdateMutation, + mockUseProjectAddonsQuery, + mockUseSelectedProjectQuery, +} = vi.hoisted(() => ({ + mockUpdatePoolerConfig: vi.fn(), + mockUseAsyncCheckPermissions: vi.fn(), + mockUseCheckEntitlements: vi.fn(), + mockUseHighAvailability: vi.fn(), + mockUseMaxConnectionsQuery: vi.fn(), + mockUsePgbouncerConfigQuery: vi.fn(), + mockUsePgbouncerConfigurationUpdateMutation: vi.fn(), + mockUseProjectAddonsQuery: vi.fn(), + mockUseSelectedProjectQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ ref: 'ha-project' }), +})) + +vi.mock('@/hooks/misc/useCheckPermissions', () => ({ + useAsyncCheckPermissions: mockUseAsyncCheckPermissions, +})) + +vi.mock('@/hooks/misc/useCheckEntitlements', () => ({ + useCheckEntitlements: mockUseCheckEntitlements, +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: mockUseSelectedProjectQuery, +})) + +vi.mock('@/data/database/max-connections-query', () => ({ + useMaxConnectionsQuery: mockUseMaxConnectionsQuery, +})) + +vi.mock('@/data/database/pgbouncer-config-query', () => ({ + usePgbouncerConfigQuery: mockUsePgbouncerConfigQuery, +})) + +vi.mock('@/data/database/pgbouncer-config-update-mutation', () => ({ + usePgbouncerConfigurationUpdateMutation: mockUsePgbouncerConfigurationUpdateMutation, +})) + +vi.mock('@/data/subscriptions/project-addons-query', () => ({ + useProjectAddonsQuery: mockUseProjectAddonsQuery, +})) + +const expectEveryQueryCall = (queryMock: ReturnType, enabled: boolean) => { + expect(queryMock).toHaveBeenCalled() + for (const [, options] of queryMock.mock.calls) { + expect(options).toEqual({ enabled }) + } +} + +describe('ConnectionPooling', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseSelectedProjectQuery.mockReturnValue({ + data: { + id: 1, + ref: 'ha-project', + infra_compute_size: 'small', + connectionString: 'postgresql://example', + }, + }) + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + mockUseAsyncCheckPermissions.mockReturnValue({ can: true }) + mockUseCheckEntitlements.mockReturnValue({ hasAccess: true }) + mockUsePgbouncerConfigQuery.mockReturnValue({ + data: undefined, + error: undefined, + isPending: false, + isError: false, + isSuccess: false, + }) + mockUseMaxConnectionsQuery.mockReturnValue({ data: undefined }) + mockUseProjectAddonsQuery.mockReturnValue({ + data: { + selected_addons: [ + { + type: 'compute_instance', + variant: { name: 'Small', identifier: 'ci_small' }, + }, + ], + }, + isSuccess: true, + }) + mockUsePgbouncerConfigurationUpdateMutation.mockReturnValue({ + mutate: mockUpdatePoolerConfig, + isPending: false, + }) + }) + + it('renders High Availability pooling settings as read-only', () => { + customRender() + + expect( + screen.getAllByText( + 'Connection pooling settings are managed automatically on High Availability projects' + ) + ).toHaveLength(1) + expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute( + 'href', + 'https://multigres.com/blog/pooling-without-choosing-a-mode' + ) + + expect(screen.queryByText('Enable IPv4 add-on')).not.toBeInTheDocument() + expect(screen.getByPlaceholderText('Managed automatically')).toBeDisabled() + expect(screen.getByDisplayValue('100000')).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + expect(mockUpdatePoolerConfig).not.toHaveBeenCalled() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, false) + expectEveryQueryCall(mockUseMaxConnectionsQuery, false) + }) + + it('does not fetch pooling config while the high availability state is pending', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: true }) + + customRender() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, false) + expectEveryQueryCall(mockUseMaxConnectionsQuery, false) + }) + + it('fetches pooling config for non high availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) + + customRender() + + expectEveryQueryCall(mockUsePgbouncerConfigQuery, true) + expectEveryQueryCall(mockUseMaxConnectionsQuery, true) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 79f46c844e132..d6d3a31638bb3 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -38,6 +38,7 @@ import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { FormActions } from '@/components/ui/Forms/FormActions' +import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice' import { InlineLink } from '@/components/ui/InlineLink' import Panel from '@/components/ui/Panel' import { useMaxConnectionsQuery } from '@/data/database/max-connections-query' @@ -46,10 +47,14 @@ import { usePgbouncerConfigurationUpdateMutation } from '@/data/database/pgbounc import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' const formId = 'pooling-configuration-form' +const HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS = 100_000 +const HA_DISABLED_TITLE = + 'Connection pooling settings are managed automatically on High Availability projects' const PoolingConfigurationFormSchema = z.object({ default_pool_size: z.preprocess( @@ -68,6 +73,8 @@ const PoolingConfigurationFormSchema = z.object({ export const ConnectionPooling = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability() + const canLoadPoolingConfig = !isHighAvailability && !isHighAvailabilityPending const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckPermissions( PermissionAction.UPDATE, 'projects', @@ -80,15 +87,18 @@ export const ConnectionPooling = () => { isPending: isLoadingPgbouncerConfig, isError: isErrorPgbouncerConfig, isSuccess: isSuccessPgbouncerConfig, - } = usePgbouncerConfigQuery({ projectRef }) + } = usePgbouncerConfigQuery({ projectRef }, { enabled: canLoadPoolingConfig }) const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler') const disablePoolModeSelection = !hasDedicatedPooler - const { data: maxConnData } = useMaxConnectionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) + const { data: maxConnData } = useMaxConnectionsQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: canLoadPoolingConfig } + ) const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery({ projectRef }) const { mutate: updatePoolerConfig, isPending: isUpdatingPoolerConfig } = @@ -120,7 +130,7 @@ export const ConnectionPooling = () => { const onSubmit: SubmitHandler> = async (data) => { const { default_pool_size } = data - if (!projectRef) return console.error('Project ref is required') + if (!projectRef || isHighAvailability) return updatePoolerConfig( { @@ -165,7 +175,24 @@ export const ConnectionPooling = () => { - {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && ( + {isHighAvailability && ( + + High Availability projects run one pooler per Postgres pod, each supporting up to{' '} + {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active or passive + connections, with pooling behavior selected automatically.{' '} + + Learn more + + . + + } + /> + )} + + {isSuccessAddons && !isHighAvailability && !disablePoolModeSelection && !hasIpv4Addon && ( { resetForm()} - helper={ - !canUpdateConnectionPoolingConfiguration - ? 'You need additional permissions to update connection pooling settings' - : undefined - } - /> + isHighAvailability ? undefined : ( + resetForm()} + helper={ + !canUpdateConnectionPoolingConfiguration + ? 'You need additional permissions to update connection pooling settings' + : undefined + } + /> + ) } > - {isLoadingPgbouncerConfig && ( + {!isHighAvailability && isLoadingPgbouncerConfig && (
{Array.from({ length: 4 }).map((_, i) => ( @@ -213,31 +242,40 @@ export const ConnectionPooling = () => {
)} - {isErrorPgbouncerConfig && ( + {!isHighAvailability && isErrorPgbouncerConfig && ( )} - {connectionPoolingUnavailable && ( + {!isHighAvailability && connectionPoolingUnavailable && ( )} - {isSuccessPgbouncerConfig && !connectionPoolingUnavailable && ( + {(isHighAvailability || + (isSuccessPgbouncerConfig && !connectionPoolingUnavailable)) && ( <>
Connection poolers

- Configuration is shared across all connection poolers. + {isHighAvailability + ? 'One pooler runs for each Postgres pod in the cluster.' + : 'Configuration is shared across all connection poolers.'}

- Shared - {!disablePoolModeSelection && Dedicated} + {isHighAvailability ? ( + High Availability + ) : ( + <> + Shared + {!disablePoolModeSelection && Dedicated} + + )}
@@ -255,11 +293,18 @@ export const ConnectionPooling = () => { layout="flex-row-reverse" label="Connection pool size" description={ -

- The maximum number of connections made to the underlying Postgres - cluster, per user+db combination. Pool size has a default of{' '} - {defaultPoolSize} based on your compute size of {computeSize}. -

+ isHighAvailability ? ( +

+ Pool size is managed automatically for each Postgres pod and cannot + be changed. +

+ ) : ( +

+ The maximum number of connections made to the underlying Postgres + cluster, per user+db combination. Pool size has a default of{' '} + {defaultPoolSize} based on your compute size of {computeSize}. +

+ ) } className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full" > @@ -267,10 +312,15 @@ export const ConnectionPooling = () => { field.onChange( isNaN(event.target.valueAsNumber) @@ -279,12 +329,15 @@ export const ConnectionPooling = () => { ) } /> - - connections - + {!isHighAvailability && ( + + connections + + )}
- {!!maxConnData && + {!isHighAvailability && + !!maxConnData && (default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && ( @@ -313,7 +366,14 @@ export const ConnectionPooling = () => { label="Max client connections" className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full" description={ - <> + isHighAvailability ? ( +

+ Each pooler can support up to{' '} + {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active + or passive client connections. This value is managed automatically + and cannot be changed. +

+ ) : (

The maximum number of concurrent client connections allowed. This value is fixed at {defaultMaxClientConn} based on your compute size @@ -324,7 +384,7 @@ export const ConnectionPooling = () => { Learn more

- + ) } > @@ -333,8 +393,16 @@ export const ConnectionPooling = () => { {...field} type="number" className="w-full" - value={pgbouncerConfig?.max_client_conn ?? ''} - placeholder={defaultMaxClientConn.toString()} + value={ + isHighAvailability + ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS + : (pgbouncerConfig?.max_client_conn ?? '') + } + placeholder={ + isHighAvailability + ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toString() + : defaultMaxClientConn.toString() + } onChange={(event) => field.onChange( isNaN(event.target.valueAsNumber) diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx new file mode 100644 index 0000000000000..3258a0327b166 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx @@ -0,0 +1,84 @@ +import { screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { PoolingModesModal } from './PoolingModesModal' +import { customRender } from '@/tests/lib/custom-render' + +const { + mockUseDatabaseSelectorStateSnapshot, + mockUseDatabaseSettingsStateSnapshot, + mockUseHighAvailability, + mockUseSupavisorConfigurationQuery, +} = vi.hoisted(() => ({ + mockUseDatabaseSelectorStateSnapshot: vi.fn(), + mockUseDatabaseSettingsStateSnapshot: vi.fn(), + mockUseHighAvailability: vi.fn(), + mockUseSupavisorConfigurationQuery: vi.fn(), +})) + +vi.mock('common', async (importOriginal) => ({ + ...(await importOriginal()), + useParams: () => ({ ref: 'ha-project' }), +})) + +vi.mock('@/hooks/misc/useHighAvailability', () => ({ + useHighAvailability: mockUseHighAvailability, +})) + +vi.mock('@/data/database/supavisor-configuration-query', () => ({ + useSupavisorConfigurationQuery: mockUseSupavisorConfigurationQuery, +})) + +vi.mock('@/state/database-selector', () => ({ + useDatabaseSelectorStateSnapshot: mockUseDatabaseSelectorStateSnapshot, +})) + +vi.mock('@/state/database-settings', () => ({ + useDatabaseSettingsStateSnapshot: mockUseDatabaseSettingsStateSnapshot, +})) + +const expectEveryQueryCall = (queryMock: ReturnType, enabled: boolean) => { + expect(queryMock).toHaveBeenCalled() + for (const [, options] of queryMock.mock.calls) { + expect(options).toEqual({ enabled }) + } +} + +describe('PoolingModesModal', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockUseDatabaseSettingsStateSnapshot.mockReturnValue({ + showPoolingModeHelper: true, + setShowPoolingModeHelper: vi.fn(), + }) + mockUseDatabaseSelectorStateSnapshot.mockReturnValue({ selectedDatabaseId: 'ha-project' }) + mockUseSupavisorConfigurationQuery.mockReturnValue({ data: undefined }) + }) + + it('renders nothing and skips the supavisor query for High Availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false }) + + const { container } = customRender() + + expect(container).toBeEmptyDOMElement() + expectEveryQueryCall(mockUseSupavisorConfigurationQuery, false) + }) + + it('does not fetch supavisor config while the high availability state is pending', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: true }) + + customRender() + + expectEveryQueryCall(mockUseSupavisorConfigurationQuery, false) + }) + + it('renders the modal and fetches supavisor config for non high availability projects', () => { + mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false }) + + customRender() + + expect(screen.getByText('Which pooling mode should I use?')).toBeInTheDocument() + expectEveryQueryCall(mockUseSupavisorConfigurationQuery, true) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx index d9ef6ce37acb6..3e16bc7b8cbd9 100644 --- a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx +++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx @@ -19,6 +19,7 @@ import { import { Markdown } from '@/components/interfaces/Markdown' import { DocsButton } from '@/components/ui/DocsButton' import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { DOCS_URL } from '@/lib/constants' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' import { useDatabaseSettingsStateSnapshot } from '@/state/database-settings' @@ -27,8 +28,12 @@ export const PoolingModesModal = () => { const { ref: projectRef } = useParams() const snap = useDatabaseSettingsStateSnapshot() const state = useDatabaseSelectorStateSnapshot() + const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability() - const { data } = useSupavisorConfigurationQuery({ projectRef: projectRef }) + const { data } = useSupavisorConfigurationQuery( + { projectRef: projectRef }, + { enabled: !isHighAvailability && !isHighAvailabilityPending } + ) const primaryConfig = data?.find((x) => x.identifier === state.selectedDatabaseId) const navigateToPoolerSettings = () => { @@ -36,6 +41,8 @@ export const PoolingModesModal = () => { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } + if (isHighAvailability) return null + return ( diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 95e1b1f34a490..2afffd1f012be 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -17,6 +17,7 @@ import { CustomDomainsConfigureHostname } from './CustomDomainsConfigureHostname import { CustomDomainsShimmerLoader } from './CustomDomainsShimmerLoader' import { CustomDomainVerify } from './CustomDomainVerify' import { SupportLink } from '@/components/interfaces/Support/SupportLink' +import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState' import { InlineLinkClassName } from '@/components/ui/InlineLink' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { @@ -24,12 +25,14 @@ import { type CustomDomainsData, } from '@/data/custom-domains/custom-domains-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' export const CustomDomainConfig = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability() const { data: organization } = useSelectedOrganizationQuery() const isBranch = Boolean(project?.parent_project_ref) const entityLabel = isBranch ? 'branch' : 'project' @@ -37,8 +40,12 @@ export const CustomDomainConfig = () => { const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota') const plan = organization?.plan?.id + const canLoadCustomDomains = !isHighAvailability && !isHighAvailabilityPending - const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery({ projectRef: ref }) + const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery( + { projectRef: ref }, + { enabled: canLoadCustomDomains } + ) const hasCustomDomainAddon = !!addons?.selected_addons.find((x) => x.type === 'custom_domain') const { @@ -50,6 +57,7 @@ export const CustomDomainConfig = () => { } = useCustomDomainsQuery( { projectRef: ref }, { + enabled: canLoadCustomDomains, refetchInterval: (query) => { const data = query.state.data // while setting up the ssl certificate, we want to poll every 5 seconds @@ -64,6 +72,28 @@ export const CustomDomainConfig = () => { const { status } = customDomainData || {} + if (isHighAvailability) { + return ( + + + + Custom domains + + Present a branded experience to your users + + + + + + + + ) + } + return ( @@ -75,7 +105,7 @@ export const CustomDomainConfig = () => { - {isLoadingAddons ? ( + {isHighAvailabilityPending || isLoadingAddons ? ( diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx new file mode 100644 index 0000000000000..01f45f3a1213d --- /dev/null +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx @@ -0,0 +1,76 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { TableRealtimeToggle } from './TableEditor' + +describe('TableRealtimeToggle', () => { + it('disables Realtime and shows the HA-specific description for HA projects', () => { + render( + + ) + + expect(screen.getByRole('checkbox', { name: 'Enable Realtime' })).toBeDisabled() + expect( + screen.getByText('Realtime is unavailable on High Availability projects.') + ).toBeInTheDocument() + expect( + screen.queryByText('Broadcast changes on this table to authorized subscribers.') + ).not.toBeInTheDocument() + }) + + it('keeps Realtime available with the standard description for non-HA projects', () => { + render( + + ) + + expect(screen.getByRole('checkbox', { name: 'Enable Realtime' })).toBeEnabled() + expect( + screen.getByText('Broadcast changes on this table to authorized subscribers.') + ).toBeInTheDocument() + expect( + screen.queryByText('Realtime is unavailable on High Availability projects.') + ).not.toBeInTheDocument() + }) + + it('prevents toggling until HA detection completes', async () => { + const user = userEvent.setup() + const onCheckedChange = vi.fn() + const { rerender } = render( + + ) + + const checkbox = screen.getByRole('checkbox', { name: 'Enable Realtime' }) + expect(checkbox).toBeDisabled() + await user.click(checkbox) + expect(onCheckedChange).not.toHaveBeenCalled() + + rerender( + + ) + + expect(checkbox).toBeEnabled() + await user.click(checkbox) + expect(onCheckedChange).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index b1f32ee4873a5..8988c2b651830 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -32,6 +32,7 @@ import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-const import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' import { useChanged } from '@/hooks/misc/useChanged' +import { useHighAvailability } from '@/hooks/misc/useHighAvailability' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -63,6 +64,44 @@ export interface TableEditorProps { apiAccessToggleHandler: TableApiAccessHandlerWithHistoryReturn } +interface TableRealtimeToggleProps { + checked: boolean + isHighAvailability: boolean + isPending: boolean + onCheckedChange: () => void +} + +export const TableRealtimeToggle = ({ + checked, + isHighAvailability, + isPending, + onCheckedChange, +}: TableRealtimeToggleProps) => { + return ( +
+ +
+ +

+ {isHighAvailability + ? 'Realtime is unavailable on High Availability projects.' + : 'Broadcast changes on this table to authorized subscribers.'} +

+
+
+ ) +} + export const TableEditor = ({ table, isDuplicating, @@ -77,6 +116,7 @@ export const TableEditor = ({ const snap = useTableEditorStateSnapshot() const tableEditorApi = useContext(TableEditorStateContext) const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all']) + const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability() const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path']) const [params, setParams] = useUrlState() @@ -220,7 +260,7 @@ export const TableEditor = ({ tableId: table?.id, importContent, isRLSEnabled: tableFields.isRLSEnabled, - isRealtimeEnabled: tableFields.isRealtimeEnabled, + isRealtimeEnabled: !isHighAvailability && tableFields.isRealtimeEnabled, isDuplicateRows: isDuplicateRows, existingForeignKeyRelations: foreignKeys, primaryKey, @@ -500,32 +540,20 @@ export const TableEditor = ({ )} {realtimeEnabled && ( -
- { - track('realtime_toggle_table_clicked', { - newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', - origin: 'tableSidePanel', - }) - onUpdateField({ - isRealtimeEnabled: !tableFields.isRealtimeEnabled, - }) - }} - /> -
- -

- Broadcast changes on this table to authorized subscribers. -

-
-
+ { + track('realtime_toggle_table_clicked', { + newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled', + origin: 'tableSidePanel', + }) + onUpdateField({ + isRealtimeEnabled: !tableFields.isRealtimeEnabled, + }) + }} + /> )} diff --git a/apps/studio/data/custom-domains/custom-domains-query.ts b/apps/studio/data/custom-domains/custom-domains-query.ts index b3fe24ba2fd52..f10416f63b7e7 100644 --- a/apps/studio/data/custom-domains/custom-domains-query.ts +++ b/apps/studio/data/custom-domains/custom-domains-query.ts @@ -110,7 +110,7 @@ export const useCustomDomainsQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const { data } = useProjectAddonsQuery({ projectRef }) + const { data } = useProjectAddonsQuery({ projectRef }, { enabled: enabled !== false }) const hasCustomDomainsAddon = !!data?.selected_addons.find((x) => x.type === 'custom_domain') return useQuery({ diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx index 965c22faa2ae1..8ae7129b577ff 100644 --- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx +++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx @@ -4,6 +4,7 @@ import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsTables } from '@/components/interfaces/Database/Publications/PublicationsTables' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -14,7 +15,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import type { NextPageWithLayout } from '@/types' -const DatabasePublications: NextPageWithLayout = () => { +const DatabasePublicationsContent = () => { const { ref, id } = useParams() const { data: project } = useSelectedProjectQuery() const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( @@ -54,6 +55,12 @@ const DatabasePublications: NextPageWithLayout = () => { ) } +const DatabasePublications: NextPageWithLayout = () => ( + + + +) + DatabasePublications.getLayout = (page) => ( {page} diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx index ded564a278aed..59cd351d97bd5 100644 --- a/apps/studio/pages/project/[ref]/database/publications/index.tsx +++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PageContainer } from 'ui-patterns/PageContainer' import { PageSection } from 'ui-patterns/PageSection' +import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability' import { PublicationsList } from '@/components/interfaces/Database/Publications/PublicationsList' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' @@ -10,7 +11,7 @@ import { NoPermission } from '@/components/ui/NoPermission' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from '@/types' -const DatabasePublications: NextPageWithLayout = () => { +const DatabasePublicationsContent = () => { const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'publications' @@ -31,6 +32,12 @@ const DatabasePublications: NextPageWithLayout = () => { ) } +const DatabasePublications: NextPageWithLayout = () => ( + + + +) + DatabasePublications.getLayout = (page) => ( {page} diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index 1e5542d5c7fa9..4d1c42e7c4556 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -30,6 +30,7 @@ import { Outlet, redirect, Scripts, + type AnyRouter, } from '@tanstack/react-router' import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' import { @@ -76,6 +77,7 @@ import { AuthProvider } from '@/lib/auth' import { configureMonacoLoader } from '@/lib/configure-monaco-loader' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants' import { TimezoneProvider, useTimezone } from '@/lib/datetime' +import { splitInternalUrl } from '@/lib/internal-url' // Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one // injects a trailing slash before the query on every nuqs write (see module). import { NuqsAdapter } from '@/lib/nuqs-tanstack-adapter' @@ -330,8 +332,21 @@ export const Route = createRootRouteWithContext()({ hash: location.hash, }) if (!match) return - const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination - throw redirect({ href, statusCode: match.permanent ? 308 : 307 }) + // `to`/`search`/`hash`, never `href`: the router treats `href` as an + // opaque (external) target, and preloading a Link whose beforeLoad + // throws `redirect({ href })` recurses forever — the preload retry + // rebuilds the origin location and re-runs this beforeLoad + // (https://github.com/TanStack/router/issues/7141). `to` is also + // basepath-relative, so no manual BASE_PATH prefix. The explicit + // `search`/`hash` fallbacks clear the incoming values rather than + // inherit them — `preserveQueryAndHash` already merged what carries over. + const { to, search, hash } = splitInternalUrl(match.destination) + throw redirect({ + to, + search: search ?? {}, + hash: hash ?? '', + statusCode: match.permanent ? 308 : 307, + }) }, component: RootComponent, shellComponent: RootDocument, diff --git a/apps/studio/routes/index.tsx b/apps/studio/routes/index.tsx index bafddebe8e436..e4211538c8b11 100644 --- a/apps/studio/routes/index.tsx +++ b/apps/studio/routes/index.tsx @@ -1,7 +1,6 @@ -import { createFileRoute, redirect } from '@tanstack/react-router' +import { createFileRoute, redirect, type AnyRouter } from '@tanstack/react-router' import { IS_PLATFORM } from '@/lib/constants' -import { stringifySearch } from '@/lib/router-search-params' // `/` is never rendered — it always redirects. Mirrors the Next.js // `redirects()` rules in next.config.ts: platform sends users to `/org` @@ -16,21 +15,30 @@ export const Route = createFileRoute('/')({ // destination — deep links like `/?next=new-project&projectName=x` must // keep `projectName`. Only the consumed `next` param is dropped (and only // when it matched); everything else passes through. - const suffix = (shouldConsumeNext: boolean) => { - const carried = { ...location.search } as Record - if (shouldConsumeNext) delete carried.next - return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}` + const carried = (shouldConsumeNext: boolean) => { + const carriedSearch: Record = { ...location.search } + if (shouldConsumeNext) delete carriedSearch.next + return carriedSearch } - // `href` instead of `to` because these targets aren't in the TanStack - // routeTree yet — they're still on the Next.js pages side during the - // migration. Swap to `to` once `/org`, `/new/new-project`, and - // `/project/default` are migrated. + // `to`, never `href`: preloading a Link whose beforeLoad throws + // `redirect({ href })` recurses forever + // (https://github.com/TanStack/router/issues/7141). The `` type arguments opt out of the registered route tree's strict + // typing so the carried free-form search record is accepted. if (IS_PLATFORM) { if (search.next === 'new-project') { - throw redirect({ href: `/new/new-project${suffix(true)}` }) + throw redirect({ + to: '/new/new-project', + search: carried(true), + hash: location.hash, + }) } - throw redirect({ href: `/org${suffix(false)}` }) + throw redirect({ to: '/org', search: carried(false), hash: location.hash }) } - throw redirect({ href: `/project/default${suffix(false)}` }) + throw redirect({ + to: '/project/default', + search: carried(false), + hash: location.hash, + }) }, }) diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/tests/components/OrganizationInvite.test.tsx index 7c0c2ff8fe2eb..baad458e0e9bf 100644 --- a/apps/studio/tests/components/OrganizationInvite.test.tsx +++ b/apps/studio/tests/components/OrganizationInvite.test.tsx @@ -156,7 +156,7 @@ describe('OrganizationInvite', () => { expect(screen.getByText('Signed in as')).toBeInTheDocument() expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects') + expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/organizations') }) test('accepts an invite with the current slug and token', async () => { diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx index 7579b282bb20a..652b1f59c8778 100644 --- a/apps/studio/tests/pages/new/[slug].test.tsx +++ b/apps/studio/tests/pages/new/[slug].test.tsx @@ -102,6 +102,19 @@ const DEFAULT_AVAILABLE_REGIONS: RegionsInfo = { }, } +const FRANKFURT = 'Central EU (Frankfurt)' + +const AVAILABLE_REGIONS_WITH_FRANKFURT: RegionsInfo = { + ...DEFAULT_AVAILABLE_REGIONS, + all: { + ...DEFAULT_AVAILABLE_REGIONS.all, + specific: [ + ...DEFAULT_AVAILABLE_REGIONS.all.specific, + { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' }, + ], + }, +} + const DEFAULT_AVAILABLE_VERSIONS: { available_versions: AvailableVersion[] } = { available_versions: [ { postgres_engine: '15', release_channel: 'ga', version: 'supabase-postgres-15.6.1.139' }, @@ -496,6 +509,28 @@ describe('project creation wizard', () => { }) describe('postgres version and orioledb', () => { + test('hides advanced configuration when orioledb is unavailable', async () => { + mockWizardEndpoints() + const onAvailableVersionsRequest = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: () => { + onAvailableVersionsRequest() + return HttpResponse.json<{ available_versions: AvailableVersion[] }>({ + available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1), + }) + }, + }) + + await renderWizard() + + await waitFor(() => expect(onAvailableVersionsRequest).toHaveBeenCalled()) + expect( + screen.queryByRole('button', { name: 'Advanced Configuration' }) + ).not.toBeInTheDocument() + }) + test('selecting orioledb shows the alpha warning and submits the oriole engine/channel', async () => { mockWizardEndpoints() const onRequest = vi.fn() @@ -520,7 +555,7 @@ describe('project creation wizard', () => { expect(body.release_channel).toBe('alpha') }) - test('rejects high availability combined with orioledb', async () => { + test('hides advanced configuration only while high availability is enabled', async () => { mockWizardEndpoints() const onRequest = vi.fn() mockCreateProject(onRequest) @@ -528,32 +563,101 @@ describe('project creation wizard', () => { await renderWizard() await fillProjectName('HA Oriole Project') + await generateAndWaitForStrongPassword() await selectRegion(/Americas/) - // The high availability toggle is now a top-level field, gated only by the entitlement. - await user.click(await screen.findByRole('switch')) - fireEvent.click(await screen.findByRole('button', { name: 'Advanced Configuration' })) await user.click(await screen.findByRole('radio', { name: /Postgres with OrioleDB/ })) - await screen.findByText('High availability is not supported with OrioleDB images') - expect(onRequest).not.toHaveBeenCalled() + const highAvailabilitySwitch = await screen.findByRole('switch') + await user.click(highAvailabilitySwitch) + + expect( + screen.queryByRole('button', { name: 'Advanced Configuration' }) + ).not.toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + expect( + await screen.findByRole('button', { name: 'Advanced Configuration' }) + ).toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + expect(onRequest.mock.calls[0][0].postgres_engine).toBe('17') + expect(onRequest.mock.calls[0][0].release_channel).toBe('ga') }) - test('blocks submission with a toast when orioledb becomes unavailable after selection', async () => { - const FRANKFURT = 'Central EU (Frankfurt)' - mockWizardEndpoints({ - availableRegions: { - ...DEFAULT_AVAILABLE_REGIONS, - all: { - ...DEFAULT_AVAILABLE_REGIONS.all, - specific: [ - ...DEFAULT_AVAILABLE_REGIONS.all.specific, - { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' }, - ], - }, + test('keeps a manually selected Postgres version when available versions refetch', async () => { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) + const onAvailableVersionsRequest = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: () => { + onAvailableVersionsRequest() + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + DEFAULT_AVAILABLE_VERSIONS + ) }, }) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('Sticky Version Project') + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + // Auto-selects the GA default once versions load + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + const requestsBeforeRegionChange = onAvailableVersionsRequest.mock.calls.length + await selectRegion(/Frankfurt/) + await waitFor(() => + expect(onAvailableVersionsRequest.mock.calls.length).toBeGreaterThan( + requestsBeforeRegionChange + ) + ) + + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + }) + + test('keeps a non-default Postgres version when the internal panel is collapsed and reopened', async () => { + mockWizardEndpoints() + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await screen.findByPlaceholderText('Project name') + await selectRegion(/Americas/) + const panelToggle = await screen.findByRole('button', { + name: 'Internal-only Configuration', + }) + fireEvent.click(panelToggle) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + fireEvent.click(panelToggle) + await waitFor(() => expect(screen.queryByLabelText('Postgres version')).toBeNull()) + fireEvent.click(panelToggle) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + ) + }) + + test('blocks submission with a toast when orioledb becomes unavailable after selection', async () => { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) const onRequest = vi.fn() mockCreateProject(onRequest) @@ -593,13 +697,19 @@ describe('project creation wizard', () => { }) describe('high availability', () => { - test('shows the high availability toggle from the entitlement, without the internal-only flag', async () => { + test('shows the high availability toggle when the entitlement is enabled', async () => { mockWizardEndpoints() await renderWizard() expect(await screen.findByText('High availability')).toBeInTheDocument() - // The toggle must not depend on the internal-only configuration section. + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText(/Free during Alpha for up to 2 projects/)).toBeInTheDocument() + expect( + screen + .getByText('High availability') + .compareDocumentPosition(screen.getByText('Compute size')) + ).toBe(Node.DOCUMENT_POSITION_FOLLOWING) expect( screen.queryByRole('button', { name: 'Internal-only Configuration' }) ).not.toBeInTheDocument() @@ -623,7 +733,43 @@ describe('project creation wizard', () => { expect(screen.queryByText('High availability')).not.toBeInTheDocument() }) - test('enabling high availability submits the flag and forces the AWS_K8S cloud provider', async () => { + test('removes the custom Postgres version field while high availability is enabled', async () => { + mockWizardEndpoints() + + await renderWizard({ + flags: { + newProjectInternalOnlyConfiguration: true, + }, + }) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + expect(screen.getByLabelText('Postgres version')).toBeDisabled() + expect(screen.queryByPlaceholderText('e.g 17.6.1.104')).not.toBeInTheDocument() + + await user.click(highAvailabilitySwitch) + + expect(await screen.findByPlaceholderText('e.g 17.6.1.104')).toBeInTheDocument() + }) + + test('shows high availability regions in a dedicated group', async () => { + mockWizardEndpoints() + + await renderWizard() + + await user.click(await screen.findByRole('switch', { name: 'Enable high availability' })) + await user.click(getSelectTriggerByLabel('Region')) + + expect(await screen.findByText('High Availability Regions')).toBeInTheDocument() + expect(screen.queryByText('General regions')).not.toBeInTheDocument() + expect(screen.queryByText('Specific regions')).not.toBeInTheDocument() + }) + + test('enabling high availability submits the fixed engine and AWS_K8S provider without a custom version', async () => { mockWizardEndpoints() const onRequest = vi.fn() mockCreateProject(onRequest) @@ -633,7 +779,7 @@ describe('project creation wizard', () => { await fillProjectName('HA Project') await generateAndWaitForStrongPassword() await user.click(await screen.findByRole('switch')) - await selectRegion(/Americas/) + await selectRegion(/East US/) fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) @@ -641,6 +787,151 @@ describe('project creation wizard', () => { const body = onRequest.mock.calls[0][0] expect(body.high_availability).toBe(true) expect(body.cloud_provider).toBe('AWS_K8S') + expect(body.postgres_engine).toBe('17') + expect(body.release_channel).toBe('ga') + expect(body.custom_supabase_internal_requests).toBeUndefined() + }) + + test('forces the high availability region over a manually selected region and restores it', async () => { + vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'staging') + try { + mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT }) + + await renderWizard() + + await screen.findByPlaceholderText('Project name') + await selectRegion(/Frankfurt/) + expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(getSelectTriggerByLabel('Region')).toHaveTextContent('East US (North Virginia)') + ) + + await user.click(highAvailabilitySwitch) + + await waitFor(() => expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT)) + } finally { + vi.unstubAllEnvs() + } + }) + + test('restores the Postgres version selection when high availability is toggled off', async () => { + mockWizardEndpoints() + // Mirror staging: no versions offered for the standard provider, so the + // selection only ever gets populated by the AWS_K8S list while HA is on. + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: async ({ request }) => { + const { provider } = (await request.json()) as { provider: string } + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + provider === 'AWS_K8S' ? DEFAULT_AVAILABLE_VERSIONS : { available_versions: [] } + ) + }, + }) + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('HA Selection Restore Project') + await generateAndWaitForStrongPassword() + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + + // The AWS_K8S versions load and auto-select a default while HA is on + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent( + 'Select a Postgres version for your project' + ) + ) + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + const body = onRequest.mock.calls[0][0] + expect(body.high_availability).toBe(false) + expect(body.postgres_engine).toBeUndefined() + expect(body.release_channel).toBeUndefined() + expect(body.custom_supabase_internal_requests).toBeUndefined() + }) + + test('restores a manually selected Postgres version selection after HA toggle', async () => { + mockWizardEndpoints() + addAPIMock({ + method: 'post', + path: '/platform/organizations/:slug/available-versions', + response: async ({ request }) => { + const { provider } = (await request.json()) as { provider: string } + return HttpResponse.json<{ available_versions: AvailableVersion[] }>( + provider === 'AWS_K8S' + ? { available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1) } + : DEFAULT_AVAILABLE_VERSIONS + ) + }, + }) + + await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } }) + + await fillProjectName('Manual Selection Restore Project') + await selectRegion(/Americas/) + fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' })) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139') + ) + await user.click(screen.getByLabelText('Postgres version')) + await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ })) + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + + const highAvailabilitySwitch = await screen.findByRole('switch', { + name: 'Enable high availability', + }) + await user.click(highAvailabilitySwitch) + await user.click(highAvailabilitySwitch) + + await waitFor(() => + expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999') + ) + }) + + test('restores the standard Postgres configuration when high availability is disabled', async () => { + mockWizardEndpoints() + const onRequest = vi.fn() + mockCreateProject(onRequest) + + await renderWizard() + + await fillProjectName('Standard Project') + await generateAndWaitForStrongPassword() + const highAvailabilitySwitch = await screen.findByRole('switch') + await user.click(highAvailabilitySwitch) + await user.click(highAvailabilitySwitch) + + fireEvent.click(screen.getByRole('button', { name: 'Create new project' })) + + await waitFor(() => expect(onRequest).toHaveBeenCalled()) + const body = onRequest.mock.calls[0][0] + expect(body.high_availability).toBe(false) + expect(body.cloud_provider).toBe('AWS') + expect(body.postgres_engine).toBeUndefined() + expect(body.custom_supabase_internal_requests).toBeUndefined() }) })