mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 20:35:07 +03:00
feat(studio): point replica deep links at Infrastructure and recommend compute
Finish the move: selectors open the Infrastructure add-replica sheet, and eligibility CTAs pre-select compute with a visible selected state.
This commit is contained in:
1 parent
3738faadd4
commit
3ff593b8b6
12 files changed
+203
-33
No files matched your search
@@ -2,6 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useForm, useWatch } from 'react-hook-form'
|
||||
import { CloudProvider } from 'shared-data'
|
||||
@@ -35,6 +36,10 @@ import {
|
||||
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3,
|
||||
} from './ui/DiskManagement.constants'
|
||||
import { NoticeBar } from './ui/NoticeBar'
|
||||
import {
|
||||
recommendComputeParser,
|
||||
subscribeRecommendCompute,
|
||||
} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute'
|
||||
import { PADDING_CLASSES } from '@/components/layouts/Scaffold'
|
||||
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
|
||||
import {
|
||||
@@ -167,6 +172,11 @@ export function DiskManagementForm({
|
||||
reValidateMode: 'onChange',
|
||||
})
|
||||
|
||||
const [recommendCompute, setRecommendCompute] = useQueryState(
|
||||
'recommendCompute',
|
||||
recommendComputeParser
|
||||
)
|
||||
|
||||
const modifiedComputeSize = useWatch({ control: form.control, name: 'computeSize' })
|
||||
|
||||
const isSuccess =
|
||||
@@ -366,6 +376,51 @@ export function DiskManagementForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccess, isDiskAttributesSuccess])
|
||||
|
||||
// From Add read replica eligibility: pre-select compute and scroll to Scaling.
|
||||
// Declared after form.reset so a cold-load with ?recommendCompute= does not get wiped.
|
||||
useEffect(() => {
|
||||
if (!recommendCompute || !isSuccess) return
|
||||
|
||||
form.setValue('computeSize', recommendCompute, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
void form.trigger(['provisionedIOPS', 'throughput'])
|
||||
setRecommendCompute(null)
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 150)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [form, isSuccess, recommendCompute, setRecommendCompute])
|
||||
|
||||
// In-page handoff from the Add read replica sheet (avoids URL write races on unmount).
|
||||
useEffect(() => {
|
||||
return subscribeRecommendCompute((size) => {
|
||||
form.setValue('computeSize', size, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
void form.trigger(['provisionedIOPS', 'throughput'])
|
||||
window.setTimeout(() => {
|
||||
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 150)
|
||||
})
|
||||
}, [form])
|
||||
|
||||
// Deep links from billing / UpgradePlanButton (e.g. #compute).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
if (window.location.hash !== '#compute') return
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
computeSettingsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fieldErrors = Object.keys(errors)
|
||||
if (fieldErrors.length === 0) return
|
||||
|
||||
@@ -30,8 +30,8 @@ export function DiskManagementPanelForm() {
|
||||
<Admonition
|
||||
type="default"
|
||||
layout="responsive"
|
||||
title="Disk Management has moved"
|
||||
description="Disk configuration is now managed alongside Project Compute on the Infrastructure page."
|
||||
title="Disk management has moved"
|
||||
description="Disk configuration is now managed alongside project compute on the Infrastructure page."
|
||||
actions={
|
||||
<Button variant="default" asChild>
|
||||
<Link href={getInfrastructurePath(projectRef)}>Go to Infrastructure</Link>
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
|
||||
render={({ field }) => (
|
||||
<div className="@container">
|
||||
<RadioGroupCard
|
||||
{...field}
|
||||
value={field.value}
|
||||
onValueChange={(value: ComputeInstanceAddonVariantId) => {
|
||||
setValue('computeSize', value, {
|
||||
shouldDirty: true,
|
||||
@@ -122,7 +122,6 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
|
||||
trigger('provisionedIOPS')
|
||||
trigger('throughput')
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
!addonsError && 'grid grid-cols-2 gap-4 @[680px]:grid-cols-3 @[900px]:grid-cols-4'
|
||||
@@ -215,7 +214,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
|
||||
</HoverCard>
|
||||
)}
|
||||
<div className="w-full flex flex-col gap-3 justify-between">
|
||||
<div className="relative px-3 opacity-50 group-data-checked:opacity-100 flex justify-between">
|
||||
<div className="relative px-3 opacity-50 group-data-[state=checked]:opacity-100 flex justify-between">
|
||||
<ComputeBadge
|
||||
className="inline-flex font-semibold"
|
||||
infraComputeSize={compute.name as InfraInstanceSize}
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ const Subscription = () => {
|
||||
</Button>
|
||||
{currentPlan && currentPlan.id !== 'free' && (
|
||||
<InitiateCancellationFlowButton variant="danger">
|
||||
Cancel Subscription
|
||||
Cancel subscription
|
||||
</InitiateCancellationFlowButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { getAddReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
@@ -38,7 +39,7 @@ export const DatabaseParametersSubMenu = ({
|
||||
const selectedDatabaseId = identifier ?? projectRef
|
||||
const selectedDatabase = databases.find((db) => db.identifier === selectedDatabaseId)
|
||||
|
||||
const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica`
|
||||
const newReplicaURL = getAddReadReplicaPath(projectRef)
|
||||
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
|
||||
+47
-14
@@ -1,11 +1,17 @@
|
||||
import { SupportCategories } from '@supabase/shared-types/out/constants'
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from 'ui'
|
||||
import { Admonition } from 'ui-patterns/Admonition'
|
||||
|
||||
import {
|
||||
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS,
|
||||
requestRecommendCompute,
|
||||
type RecommendedComputeForReadReplicas,
|
||||
} from '../recommendCompute'
|
||||
import { useCheckEligibilityDeployReplica } from './useCheckEligibilityDeployReplica'
|
||||
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
@@ -21,6 +27,15 @@ export const ReadReplicaEligibilityWarnings = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const isFreePlan = org?.plan?.id === 'free'
|
||||
|
||||
const [, setAddReplica] = useQueryState(
|
||||
'addReplica',
|
||||
parseAsBoolean.withDefault(false).withOptions({
|
||||
history: 'push',
|
||||
clearOnDefault: true,
|
||||
})
|
||||
)
|
||||
|
||||
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
|
||||
|
||||
@@ -62,6 +77,16 @@ export const ReadReplicaEligibilityWarnings = () => {
|
||||
}
|
||||
}, [projectDetail?.is_physical_backups_enabled, isProjectDetailSuccess])
|
||||
|
||||
const handleRecommendCompute = (size: RecommendedComputeForReadReplicas) => {
|
||||
// Notify the infrastructure form before closing the sheet. Closing first
|
||||
// unmounts this component and previously dropped a follow-up URL write.
|
||||
const delivered = requestRecommendCompute(size)
|
||||
setAddReplica(false)
|
||||
if (!delivered) {
|
||||
toast.error('Could not open compute settings. Scroll to Scaling and choose a size manually.')
|
||||
}
|
||||
}
|
||||
|
||||
if (hasOverdueInvoices) {
|
||||
return (
|
||||
<Admonition type="warning" title="Your organization has overdue invoices">
|
||||
@@ -132,13 +157,22 @@ export const ReadReplicaEligibilityWarnings = () => {
|
||||
This is to ensure that read replicas can keep up with the primary databases' activities.
|
||||
</p>
|
||||
<div className="flex items-center gap-x-2 mt-2">
|
||||
<UpgradePlanButton
|
||||
variant="default"
|
||||
plan="Pro"
|
||||
addon="computeSize"
|
||||
source="read-replicas"
|
||||
featureProposition="deploy Read Replicas"
|
||||
/>
|
||||
{isFreePlan ? (
|
||||
<UpgradePlanButton
|
||||
variant="default"
|
||||
plan="Pro"
|
||||
addon="computeSize"
|
||||
source="read-replicas"
|
||||
featureProposition="deploy Read Replicas"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => handleRecommendCompute(RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.minimum)}
|
||||
>
|
||||
Change to Small compute
|
||||
</Button>
|
||||
)}
|
||||
<DocsButton href={`${DOCS_URL}/guides/platform/read-replicas#prerequisites`} />
|
||||
</div>
|
||||
</Admonition>
|
||||
@@ -229,16 +263,15 @@ export const ReadReplicaEligibilityWarnings = () => {
|
||||
<span className="text-foreground">{READ_REPLICAS_MAX_COUNT}</span> replicas if your
|
||||
project is on an XL compute or higher.
|
||||
</p>
|
||||
<UpgradePlanButton
|
||||
<Button
|
||||
variant="default"
|
||||
plan="Pro"
|
||||
addon="computeSize"
|
||||
source="read-replicas"
|
||||
featureProposition="deploy Read Replicas"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
handleRecommendCompute(RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.unlockMaxReplicas)
|
||||
}
|
||||
>
|
||||
Change compute size
|
||||
</UpgradePlanButton>
|
||||
Change to XL compute
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Admonition>
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
requestRecommendCompute,
|
||||
subscribeRecommendCompute,
|
||||
} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/recommendCompute'
|
||||
|
||||
describe('recommendCompute bridge', () => {
|
||||
afterEach(() => {
|
||||
// Clear any leftover subscriber between tests.
|
||||
subscribeRecommendCompute(() => {})()
|
||||
})
|
||||
|
||||
it('delivers the recommended size to the active subscriber', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeRecommendCompute(listener)
|
||||
|
||||
expect(requestRecommendCompute('ci_small')).toBe(true)
|
||||
expect(listener).toHaveBeenCalledWith('ci_small')
|
||||
|
||||
unsubscribe()
|
||||
expect(requestRecommendCompute('ci_xlarge')).toBe(false)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('replaces the previous subscriber', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
subscribeRecommendCompute(first)
|
||||
subscribeRecommendCompute(second)
|
||||
|
||||
expect(requestRecommendCompute('ci_xlarge')).toBe(true)
|
||||
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledWith('ci_xlarge')
|
||||
})
|
||||
})
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { parseAsStringEnum } from 'nuqs'
|
||||
|
||||
/** Compute sizes recommended from the Add read replica sheet (close sheet → scroll + pre-select). */
|
||||
export const RECOMMENDED_COMPUTE_FOR_READ_REPLICAS = {
|
||||
/** Minimum size that can deploy any read replicas. */
|
||||
minimum: 'ci_small',
|
||||
/** Size that unlocks the default max replica count (5). */
|
||||
unlockMaxReplicas: 'ci_xlarge',
|
||||
} as const
|
||||
|
||||
export type RecommendedComputeForReadReplicas =
|
||||
(typeof RECOMMENDED_COMPUTE_FOR_READ_REPLICAS)[keyof typeof RECOMMENDED_COMPUTE_FOR_READ_REPLICAS]
|
||||
|
||||
export const recommendComputeParser = parseAsStringEnum<RecommendedComputeForReadReplicas>([
|
||||
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.minimum,
|
||||
RECOMMENDED_COMPUTE_FOR_READ_REPLICAS.unlockMaxReplicas,
|
||||
]).withOptions({ history: 'replace', clearOnDefault: true })
|
||||
|
||||
type RecommendComputeListener = (size: RecommendedComputeForReadReplicas) => void
|
||||
|
||||
/**
|
||||
* In-page bridge from the Add read replica sheet → DiskManagementForm.
|
||||
* Avoids a nuqs race where closing the sheet unmounts the CTA before a second
|
||||
* query-param write can land.
|
||||
*/
|
||||
let recommendComputeListener: RecommendComputeListener | null = null
|
||||
|
||||
export function subscribeRecommendCompute(listener: RecommendComputeListener) {
|
||||
recommendComputeListener = listener
|
||||
return () => {
|
||||
if (recommendComputeListener === listener) {
|
||||
recommendComputeListener = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when a subscriber received the recommendation. */
|
||||
export function requestRecommendCompute(size: RecommendedComputeForReadReplicas) {
|
||||
if (!recommendComputeListener) return false
|
||||
recommendComputeListener(size)
|
||||
return true
|
||||
}
|
||||
@@ -28,12 +28,10 @@ export const ResizingState = () => {
|
||||
<Loader2 className="animate-spin text-foreground-light" size={18} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>Resizing Project Compute size</p>
|
||||
<p>Resizing project</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Your project is being restarted to apply compute size changes.
|
||||
</p>
|
||||
<p className="text-sm text-foreground-light">
|
||||
This can take a few minutes. Project will be offline while it is being restarted.
|
||||
Your project is being restarted to apply compute size changes. It will remain
|
||||
offline until fully restarted. This can take a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,12 @@ import {
|
||||
TooltipTrigger,
|
||||
} from 'ui'
|
||||
|
||||
import { REPLICA_STATUS } from '../interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
|
||||
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
|
||||
import {
|
||||
getAddReadReplicaPath,
|
||||
getInfrastructurePath,
|
||||
} from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
|
||||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||||
@@ -74,7 +77,7 @@ export const DatabaseSelector = ({
|
||||
|
||||
const selectedAdditionalOption = additionalOptions.find((x) => x.id === selectedDatabaseId)
|
||||
|
||||
const newReplicaURL = `/project/${projectRef}/database/replication?destinationType=Read+Replica`
|
||||
const newReplicaURL = getAddReadReplicaPath(projectRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (_selectedDatabaseId && !isForm) state.setSelectedDatabaseId(_selectedDatabaseId)
|
||||
|
||||
@@ -72,7 +72,7 @@ export const UpgradePlanButton = ({
|
||||
? `/org/${slug ?? '_'}/billing?panel=costControl&source=${source}`
|
||||
: isOnPaidPlanAndRequestingToPurchaseAddon
|
||||
? addon === 'computeSize'
|
||||
? getInfrastructurePath(ref)
|
||||
? `${getInfrastructurePath(ref)}#compute`
|
||||
: `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
|
||||
: `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
|
||||
|
||||
|
||||
+4
-3
@@ -16,7 +16,7 @@ vi.mock('@/data/database/enable-physical-backups-mutation', () => ({
|
||||
useEnablePhysicalBackupsMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
}))
|
||||
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
|
||||
useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }),
|
||||
useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org', plan: { id: 'pro' } } }),
|
||||
}))
|
||||
vi.mock('@/hooks/misc/useSelectedProject', () => ({
|
||||
useSelectedProjectQuery: () => ({ data: { dbVersion: 'supabase-postgres-15.1.0' } }),
|
||||
@@ -52,6 +52,7 @@ describe('ReadReplicaEligibilityWarnings – below small compute', () => {
|
||||
"This is to ensure that read replicas can keep up with the primary databases' activities."
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /change to small compute/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,7 +68,7 @@ describe('ReadReplicaEligibilityWarnings – max replicas reached', () => {
|
||||
screen.getByText('You can only deploy up to 4 read replicas at once')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText(/you may deploy up to/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /change compute size/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /change to xl compute/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does NOT show the compute upsell when already at the default cap (XL+)', () => {
|
||||
@@ -82,6 +83,6 @@ describe('ReadReplicaEligibilityWarnings – max replicas reached', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText(/you may deploy up to/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/XL compute or higher/i)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /change compute size/i })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /change to xl compute/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user