chore: rework disk validation and cleanup (#34533)

This commit is contained in:
Joshen Lim authored and GitHub committed 2025-04-03 16:58:23 +08:00
1 parent 673449cfa9
commit 0f0b02e0d9
23 files changed
+240 -1556

No files matched your search

@@ -8,15 +8,12 @@ import {
calculateMaxIopsAllowedForDiskSizeWithio2,
formatNumber,
} from './DiskManagement.utils'
import { DISK_LIMITS, DiskType, IOPS_RANGE, THROUGHPUT_RANGE } from './ui/DiskManagement.constants'
import { DISK_LIMITS, DiskType } from './ui/DiskManagement.constants'
import { CloudProvider } from 'shared-data'
const baseSchema = z.object({
storageType: z.enum(['io2', 'gp3']).describe('Type of storage: io2 or gp3'),
totalSize: z
.number()
.int('Value must be an integer')
.min(8, { message: 'Allocated disk size must be at least 8 GB.' })
.describe('Allocated disk size in GB'),
totalSize: z.number().int('Value must be an integer').describe('Allocated disk size in GB'),
provisionedIOPS: z.number().describe('Provisioned IOPS for storage type'),
throughput: z.number().optional().describe('Throughput in MB/s for gp3'),
computeSize: z
@@ -44,11 +41,21 @@ const baseSchema = z.object({
.nullable(),
})
export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
export const CreateDiskStorageSchema = (defaultTotalSize: number, cloudProvider: CloudProvider) => {
const isFlyProject = cloudProvider === 'FLY'
const schema = baseSchema.superRefine((data, ctx) => {
const { storageType, totalSize, provisionedIOPS, throughput, maxSizeGb } = data
if (totalSize < defaultTotalSize) {
if (!isFlyProject && totalSize < 8) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Allocated disk size must be at least 8 GB.',
path: ['totalSize'],
})
}
if (!isFlyProject && totalSize < defaultTotalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Disk size cannot be reduced in size. Reduce your database size and then head to the Infrastructure settings and go through a Postgres version upgrade to right-size your disk.`,
@@ -57,7 +64,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
}
// Validate maxSizeGb cannot be lower than totalSize
if (!!maxSizeGb && maxSizeGb < totalSize) {
if (!isFlyProject && !!maxSizeGb && maxSizeGb < totalSize) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Max disk size cannot be lower than the current disk size. Must be at least ${formatNumber(totalSize)} GB.`,
@@ -65,23 +72,23 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
})
}
if (storageType === 'io2') {
if (!isFlyProject && storageType === 'io2') {
// Validation rules for io2
if (provisionedIOPS > IOPS_RANGE[DiskType.IO2].max) {
if (provisionedIOPS > DISK_LIMITS[DiskType.IO2].maxIops) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `IOPS can not exceed ${formatNumber(IOPS_RANGE[DiskType.IO2].max)} for io2 Disk type. Please reach out to support if you need higher IOPS than this.`,
message: `IOPS can not exceed ${formatNumber(DISK_LIMITS[DiskType.IO2].maxIops)} for io2 Disk type. Please reach out to support if you need higher IOPS than this.`,
path: ['provisionedIOPS'],
})
}
const maxIOPSforDiskSizeWithio2 = calculateMaxIopsAllowedForDiskSizeWithio2(totalSize)
if (provisionedIOPS < IOPS_RANGE[DiskType.IO2].min) {
if (provisionedIOPS < DISK_LIMITS[DiskType.IO2].minIops) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Provisioned IOPS must be at least ${formatNumber(IOPS_RANGE[DiskType.IO2].min)}`,
message: `Provisioned IOPS must be at least ${formatNumber(DISK_LIMITS[DiskType.IO2].minIops)}`,
path: ['provisionedIOPS'],
})
} else if (provisionedIOPS > maxIOPSforDiskSizeWithio2) {
@@ -122,21 +129,21 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
}
}
if (storageType === 'gp3') {
if (!isFlyProject && storageType === 'gp3') {
const maxIopsAllowedForDiskSizeWithGp3 = calculateMaxIopsAllowedForDiskSizeWithGp3(totalSize)
if (provisionedIOPS > IOPS_RANGE[DiskType.GP3].max) {
if (provisionedIOPS > DISK_LIMITS[DiskType.GP3].maxIops) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `IOPS can not exceed ${formatNumber(IOPS_RANGE[DiskType.GP3].max)} for GP3 Disk. Change the Disk type to io2 for higher IOPS support.`,
message: `IOPS can not exceed ${formatNumber(DISK_LIMITS[DiskType.GP3].maxIops)} for GP3 Disk. Change the Disk type to io2 for higher IOPS support.`,
path: ['provisionedIOPS'],
})
}
if (provisionedIOPS < IOPS_RANGE[DiskType.GP3].min) {
if (provisionedIOPS < DISK_LIMITS[DiskType.GP3].minIops) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `IOPS must be at least ${formatNumber(IOPS_RANGE[DiskType.GP3].min)}`,
message: `IOPS must be at least ${formatNumber(DISK_LIMITS[DiskType.GP3].minIops)}`,
path: ['provisionedIOPS'],
})
} else if (provisionedIOPS > maxIopsAllowedForDiskSizeWithGp3) {
@@ -161,7 +168,7 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
if (throughput !== undefined) {
const maxThroughput = Math.min(0.25 * provisionedIOPS, 1000)
if (throughput > THROUGHPUT_RANGE['gp3'].max) {
if (throughput > DISK_LIMITS['gp3'].maxThroughput) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Throughput can not exceed ${formatNumber(maxThroughput)} MB/s`,
@@ -176,10 +183,10 @@ export const CreateDiskStorageSchema = (defaultTotalSize: number) => {
path: ['throughput'],
})
}
if (throughput < THROUGHPUT_RANGE[DiskType.GP3].min) {
if (throughput < DISK_LIMITS[DiskType.GP3].minThroughput) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Throughput must be at least ${formatNumber(THROUGHPUT_RANGE[DiskType.GP3].min)} MB/s`,
message: `Throughput must be at least ${formatNumber(DISK_LIMITS[DiskType.GP3].minThroughput)} MB/s`,
path: ['throughput'],
})
}
@@ -4,7 +4,6 @@ import { useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronRight } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
@@ -30,7 +29,6 @@ import { AddonVariantId } from 'data/subscriptions/types'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { GB, PROJECT_STATUS } from 'lib/constants'
import {
Button,
@@ -55,13 +53,10 @@ import { IOPSField } from './fields/IOPSField'
import { StorageTypeField } from './fields/StorageTypeField'
import { ThroughputField } from './fields/ThroughputField'
import { DiskCountdownRadial } from './ui/DiskCountdownRadial'
import {
DiskType,
IOPS_RANGE,
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3,
} from './ui/DiskManagement.constants'
import { DiskType, RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3 } from './ui/DiskManagement.constants'
import { NoticeBar } from './ui/NoticeBar'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
import { CloudProvider } from 'shared-data'
export function DiskManagementForm() {
// isLoading is used to avoid a useCheckPermissions() race condition
@@ -69,14 +64,13 @@ export function DiskManagementForm() {
const org = useSelectedOrganization()
const { ref: projectRef } = useParams()
const queryClient = useQueryClient()
const router = useRouter()
const diskAndComputeForm = useFlag('diskAndComputeForm')
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
(warning) => warning.project === project?.ref
)
const isReadOnlyMode = projectResourceWarnings?.is_readonly_mode_enabled
const isFlyArchitecture = project?.cloud_provider === 'FLY'
/**
* Permissions
@@ -123,27 +117,35 @@ export function DiskManagementForm() {
}
}
},
enabled: project != null && !isFlyArchitecture,
}
)
const { isSuccess: isAddonsSuccess } = useProjectAddonsQuery({ projectRef })
const { isWithinCooldownWindow, isSuccess: isCooldownSuccess } =
useRemainingDurationForDiskAttributeUpdate({
projectRef,
enabled: project != null && !isFlyArchitecture,
})
const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery({
projectRef,
})
const { data: diskUtil, isSuccess: isDiskUtilizationSuccess } = useDiskUtilizationQuery(
{
projectRef,
},
{ enabled: project != null && !isFlyArchitecture }
)
const { data: subscription, isSuccess: isSubscriptionSuccess } = useOrgSubscriptionQuery({
orgSlug: org?.slug,
})
const { data: diskAutoscaleConfig, isSuccess: isDiskAutoscaleConfigSuccess } =
useDiskAutoscaleCustomConfigQuery({ projectRef })
useDiskAutoscaleCustomConfigQuery(
{ projectRef },
{ enabled: project != null && !isFlyArchitecture }
)
/**
* Handle default values
*/
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0, iops: 0 }
const { growth_percent, max_size_gb, min_increment_gb } = diskAutoscaleConfig ?? {}
const defaultValues = {
storageType: type ?? DiskType.GP3,
@@ -159,7 +161,9 @@ export function DiskManagementForm() {
}
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(CreateDiskStorageSchema(defaultValues.totalSize)),
resolver: zodResolver(
CreateDiskStorageSchema(defaultValues.totalSize, project?.cloud_provider as CloudProvider)
),
defaultValues,
mode: 'onBlur',
reValidateMode: 'onChange',
@@ -187,11 +191,12 @@ export function DiskManagementForm() {
const totalSize = formState.defaultValues?.totalSize || 0
const usedPercentage = (usedSize / totalSize) * 100
const isFlyArchitecture = project?.cloud_provider === 'FLY'
const disableIopsThroughputConfig =
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(form.watch('computeSize')) &&
subscription?.plan.id !== 'free'
const isBranch = project?.parent_project_ref !== undefined
const disableDiskInputs =
isRequestingChanges ||
isPlanUpgradeRequired ||
@@ -242,18 +247,11 @@ export function DiskManagementForm() {
) {
willUpdateDiskConfiguration = true
if (
payload.storageType === 'gp3' &&
RESTRICTED_COMPUTE_FOR_THROUGHPUT_ON_GP3.includes(payload.computeSize)
) {
payload.provisionedIOPS = IOPS_RANGE[DiskType.GP3].min
}
await updateDiskConfiguration({
ref: projectRef,
provisionedIOPS: payload.provisionedIOPS,
provisionedIOPS: payload.provisionedIOPS!,
storageType: payload.storageType,
totalSize: payload.totalSize,
totalSize: payload.totalSize!,
throughput: payload.throughput,
})
}
@@ -301,13 +299,6 @@ export function DiskManagementForm() {
}
}, [isSuccess, isDiskAttributesSuccess])
// Redirect logic incase disk and compute feature is not live yet
useEffect(() => {
if (diskAndComputeForm !== undefined && !diskAndComputeForm && projectRef) {
router.push(`/project/${projectRef}/settings/addons?panel=computeInstance`)
}
}, [diskAndComputeForm, projectRef, router])
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-8">
@@ -357,7 +348,11 @@ export function DiskManagementForm() {
type="default"
visible={isFlyArchitecture}
title="Disk configuration is not available on Fly Postgres"
description="Please contact Fly support if you need to update your disk configuration"
description={
isBranch
? 'Delete and recreate your Preview Branch to configure disk size. It was deployed on an older branching infrastructure.'
: 'The Fly Postgres offering is deprecated - please migrate your instance to Supabase to configure your disk.'
}
/>
{!isFlyArchitecture && (
<>
@@ -427,11 +422,11 @@ export function DiskManagementForm() {
</div>
<Separator />
<div className="px-8 flex flex-col gap-y-8">
<StorageTypeField form={form} disableInput={disableDiskInputs} />
<NoticeBar
type="default"
visible={disableIopsThroughputConfig}
title={`IOPS ${form.getValues('storageType') === 'gp3' ? 'and Throughput ' : ''}configuration requires LARGE Compute size or above`}
title="Adjusting disk configuration requires LARGE Compute size or above"
description={`Increase your compute size to adjust your disk's storage type, ${form.getValues('storageType') === 'gp3' ? 'IOPS, ' : ''} and throughput`}
actions={
<Button
type="default"
@@ -443,6 +438,10 @@ export function DiskManagementForm() {
</Button>
}
/>
<StorageTypeField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
/>
<IOPSField
form={form}
disableInput={disableIopsThroughputConfig || disableDiskInputs}
@@ -1,334 +1,13 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { AnimatePresence, motion } from 'framer-motion'
import { HelpCircle, InfoIcon, RotateCcw } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { useParams } from 'common'
import DiskSpaceBar from 'components/interfaces/DiskManagement/ui/DiskSpaceBar'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import {
useDiskAttributesQuery,
useRemainingDurationForDiskAttributeUpdate,
} from 'data/config/disk-attributes-query'
import { useUpdateDiskAttributesMutation } from 'data/config/disk-attributes-update-mutation'
import { useDiskUtilizationQuery } from 'data/config/disk-utilization-query'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { GB } from 'lib/constants'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Badge,
Button,
Card,
CardContent,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
Input_Shadcn_ as Input,
RadioGroupCard,
RadioGroupCardItem,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
import { Markdown } from '../Markdown'
import { CreateDiskStorageSchema, DiskStorageSchemaType } from './DiskManagement.schema'
import {
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
} from './DiskManagement.utils'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
import { BillingChangeBadge } from './ui/BillingChangeBadge'
import { DiskCountdownRadial } from './ui/DiskCountdownRadial'
import {
COMPUTE_MAX_IOPS,
COMPUTE_MAX_THROUGHPUT,
DiskType,
IOPS_RANGE,
PLAN_DETAILS,
THROUGHPUT_RANGE,
} from './ui/DiskManagement.constants'
import {
DiskManagementDiskSizeReadReplicas,
DiskManagementIOPSReadReplicas,
DiskManagementThroughputReadReplicas,
} from './ui/DiskManagementReadReplicas'
import { Button } from 'ui'
import { NoticeBar } from './ui/NoticeBar'
import { SpendCapDisabledSection } from './ui/SpendCapDisabledSection'
// [Joshen] Only used for non AWS projects
export function DiskManagementPanelForm() {
const { project } = useProjectContext()
const org = useSelectedOrganization()
const { ref: projectRef } = useParams()
const diskAndComputeFormEnabled = useFlag('diskAndComputeForm')
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false)
const [remainingTime, setRemainingTime] = useState(0)
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
const canUpdateDiskConfiguration = useCheckPermissions(PermissionAction.UPDATE, 'projects', {
resource: {
project_id: project?.id,
},
})
const { data: databases } = useReadReplicasQuery({ projectRef })
const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef)
const { data, isSuccess } = useDiskAttributesQuery(
{ projectRef },
{
refetchInterval,
refetchOnWindowFocus: false,
onSuccess: (data) => {
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const formValues = {
storageType: type,
provisionedIOPS: iops,
throughput: throughput_mbps,
totalSize: size_gb,
}
if (!('requested_modification' in data)) {
if (refetchInterval !== false) {
form.reset(formValues)
setRefetchInterval(false)
toast.success('Disk configuration changes have been successfully applied!')
}
}
},
}
)
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const isRequestingChanges = data?.requested_modification !== undefined
const { remainingDuration: initialRemainingTime, isWithinCooldownWindow } =
useRemainingDurationForDiskAttributeUpdate({
projectRef,
})
const { data: addons } = useProjectAddonsQuery({ projectRef })
const currentCompute = (addons?.selected_addons ?? []).find(
(x: { type: string }) => x.type === 'compute_instance'
)?.variant
const maxIopsBasedOnCompute =
COMPUTE_MAX_IOPS[(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_MAX_IOPS]
const maxThroughputBasedOnCompute =
COMPUTE_MAX_THROUGHPUT[
(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_MAX_THROUGHPUT
]
const { data: subscription } = useOrgSubscriptionQuery({
orgSlug: org?.slug,
})
const planId = subscription?.plan.id ?? 'free'
const isPlanUpgradeRequired =
subscription?.plan.id === 'pro' && !subscription.usage_billing_enabled
const { data: diskUtil } = useDiskUtilizationQuery({ projectRef })
const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100
const { mutate: updateDiskConfigurationRQ, isLoading: isUpdatingDiskConfiguration } =
useUpdateDiskAttributesMutation({
onSuccess: (_, vars) => {
toast.success(
'Successfully requested disk configuration changes! Your changes will be applied shortly'
)
const { ref, ...formData } = vars
setIsDialogOpen(false)
setRefetchInterval(3000)
form.reset(formData as DiskStorageSchemaType)
},
})
const defaultValues = {
storageType: type || DiskType.GP3,
provisionedIOPS: iops,
throughput: throughput_mbps,
totalSize: size_gb,
computeSize: undefined,
}
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(CreateDiskStorageSchema(defaultValues.totalSize)),
defaultValues,
mode: 'onBlur',
reValidateMode: 'onChange',
})
const { watch, setValue, trigger, control, formState } = form
const watchedStorageType = watch('storageType')
const watchedTotalSize = watch('totalSize')
const watchedIOPS = watch('provisionedIOPS')
const { dirtyFields } = formState // Destructure dirtyFields from formState
const isAllocatedStorageDirty = !!dirtyFields.totalSize // Check if 'allocatedStorage' is dirty
const disableInput =
isRequestingChanges ||
isPlanUpgradeRequired ||
isWithinCooldownWindow ||
!canUpdateDiskConfiguration
const { includedDiskGB: includedDiskGBMeta } = PLAN_DETAILS[planId]
const includedDiskGB = includedDiskGBMeta[watchedStorageType]
const minIOPS = IOPS_RANGE[watchedStorageType]?.min ?? 0
const maxIOPS =
watchedStorageType === 'gp3'
? Math.min(500 * watchedTotalSize, IOPS_RANGE[DiskType.GP3].max)
: Math.min(1000 * watchedTotalSize, IOPS_RANGE[DiskType.IO2].max)
const minThroughput =
watchedStorageType === 'gp3' ? THROUGHPUT_RANGE[watchedStorageType]?.min ?? 0 : 0
const maxThroughput =
watchedStorageType === 'gp3'
? Math.min(0.25 * watchedIOPS, THROUGHPUT_RANGE[DiskType.GP3].max)
: undefined
const onSubmit = async (data: DiskStorageSchemaType) => {
if (projectRef === undefined) return console.error('Project ref is required')
updateDiskConfigurationRQ({ ref: projectRef, ...data })
}
// i hate typescript enums
// gotta clean this up but it allows me to remove type casts for now
function getEnumFromFormValue(value: string) {
if (value === 'io2') return DiskType.IO2
if (value === 'gp3') return DiskType.GP3
return DiskType.IO2
}
const newStorageType = form.getValues('storageType')
const diskSizePrice = calculateDiskSizePrice({
planId,
oldSize: form.formState.defaultValues?.totalSize || 0,
oldStorageType: getEnumFromFormValue(form.formState.defaultValues?.storageType || 'io2'),
newSize: form.getValues('totalSize'),
newStorageType: getEnumFromFormValue(newStorageType),
})
const iopsPrice = calculateIOPSPrice({
oldStorageType: form.formState.defaultValues?.storageType as DiskType,
oldProvisionedIOPS: form.formState.defaultValues?.provisionedIOPS || 0,
newStorageType: form.getValues('storageType') as DiskType,
newProvisionedIOPS: form.getValues('provisionedIOPS'),
})
const throughputPrice = calculateThroughputPrice({
storageType: form.getValues('storageType') as DiskType,
newThroughput: form.getValues('throughput') || 0,
oldThroughput: form.formState.defaultValues?.throughput || 0,
})
useEffect(() => {
// Initialize field values properly when data has been loaded
if (isSuccess) form.reset(defaultValues)
}, [isSuccess])
// Watch storageType and allocatedStorage to adjust constraints dynamically
useEffect(() => {
if (watchedStorageType === 'io2') {
setValue('throughput', undefined) // Throughput is not configurable for 'io2'
} else if (watchedStorageType === 'gp3') {
// Ensure throughput is within the allowed range if it's greater than or equal to 400 GB
const currentThroughput = form.getValues('throughput')
const { min, max } = THROUGHPUT_RANGE[DiskType.GP3]
if (!currentThroughput || currentThroughput < min || currentThroughput > max) {
setValue('throughput', min) // Reset to default if undefined or out of bounds
}
}
}, [watchedStorageType, watchedTotalSize, setValue, form])
useEffect(() => {
if (initialRemainingTime > 0) setRemainingTime(initialRemainingTime)
}, [initialRemainingTime])
useEffect(() => {
if (remainingTime <= 0) return
const timer = setInterval(() => {
setRemainingTime(Math.max(0, remainingTime - 1))
}, 1000)
return () => clearInterval(timer)
}, [remainingTime])
if (diskAndComputeFormEnabled) {
return (
<div id="disk-management">
<FormHeader
title="Disk Management"
docsUrl="https://supabase.com/docs/guides/platform/database-size#disk-management"
/>
<NoticeBar
visible={true}
type="default"
title="Disk Management has moved"
description="Disk configuration is now managed alongside Project Compute on the new Compute and Disk page."
actions={
<Button type="default" asChild>
<Link
href={`/project/${projectRef}/settings/compute-and-disk`}
className="!no-underline"
>
Go to Compute and Disk
</Link>
</Button>
}
/>
</div>
)
}
if (planId === 'free') {
return (
<div id="disk-management">
<FormHeader
title="Disk Management"
docsUrl="https://supabase.com/docs/guides/platform/database-size#disk-management"
/>
<Alert_Shadcn_>
<InfoIcon />
<AlertTitle_Shadcn_>
Disk size configuration is not available for projects on the Free Plan
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
If you are intending to use more than 500MB of disk space, then you will need to
upgrade to at least the Pro Plan.
</p>
<Button asChild type="default" className="mt-3">
<Link
target="_blank"
rel="noreferrer"
href={`/org/${org?.slug}/billing?panel=subscriptionPlan&source=diskManagementPanelDiskSize`}
>
Upgrade plan
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
)
}
return (
<div id="disk-management">
@@ -336,470 +15,22 @@ export function DiskManagementPanelForm() {
title="Disk Management"
docsUrl="https://supabase.com/docs/guides/platform/database-size#disk-management"
/>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="-space-y-px">
<Card className="bg-surface-100 rounded-b-none">
<CardContent className="transition-all duration-500 ease-in-out py-10 flex flex-col gap-10 px-8">
<FormField_Shadcn_
name="storageType"
control={form.control}
render={({ field }) => (
<FormItemLayout layout="horizontal" label="Storage type">
<FormControl_Shadcn_>
<RadioGroupCard
className="flex flex-wrap gap-3"
{...field}
onValueChange={async (e) => {
field.onChange(e)
// only trigger provisionedIOPS due to other input being hidden
await form.trigger('provisionedIOPS')
await form.trigger('totalSize')
}}
defaultValue={field.value}
disabled={disableInput}
>
<FormItem_Shadcn_ asChild>
<FormControl_Shadcn_>
<RadioGroupCardItem
className="grow p-3 px-5"
disabled={disableInput}
value="gp3"
showIndicator={false}
// @ts-ignore
label={
<div className="flex flex-col gap-1">
<div className="flex gap-3 items-center">
<span className="text-sm">General Purpose SSD</span>{' '}
<div>
<Badge
variant={'outline'}
className="font-mono bg-alternative bg-opacity-100"
>
gp3
</Badge>
</div>
</div>
<p className="text-foreground-light">
gp3 provides a balance between price and performance
</p>
</div>
}
/>
</FormControl_Shadcn_>
</FormItem_Shadcn_>
<FormItem_Shadcn_ asChild>
<FormControl_Shadcn_>
<RadioGroupCardItem
className="grow p-3 px-5"
disabled={disableInput}
value="io2"
showIndicator={false}
// @ts-ignore
label={
<div className="flex flex-col gap-1">
<div className="flex gap-3 items-center">
<span className="text-sm">Provisioned IOPS SSD</span>{' '}
<div>
<Badge
variant={'outline'}
className="font-mono bg-alternative bg-opacity-100"
>
io2
</Badge>
</div>
</div>
<p className="text-foreground-light">
io2 offers high IOPS for mission-critical applications.
</p>
</div>
}
/>
</FormControl_Shadcn_>
</FormItem_Shadcn_>
</RadioGroupCard>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="provisionedIOPS"
render={({ field }) => (
<FormItemLayout
layout="horizontal"
label="IOPS"
description={
<div className="flex flex-col gap-y-2">
<div>
{watchedStorageType === 'io2' ? (
<div className="flex items-center gap-x-2">
<span>
IOPS must be{' '}
{watchedTotalSize >= 8
? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.`
: `at least ${minIOPS}`}
</span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger>
<TooltipContent side="bottom">
For io2 storage type, min IOPS is at {minIOPS}, while max IOPS
is at 1000 * disk size in GB or{' '}
{IOPS_RANGE[DiskType.IO2].max.toLocaleString()}, whichever is
lower
</TooltipContent>
</Tooltip>
</div>
) : (
<div className="flex items-center gap-x-2">
<span>
IOPS must be{' '}
{watchedTotalSize >= 8
? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.`
: `at least ${minIOPS.toLocaleString()}`}
</span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64">
For gp3 storage type, min IOPS is at {minIOPS} while max IOPS is
at 500 * disk size in GB or{' '}
{IOPS_RANGE[DiskType.GP3].max.toLocaleString()}, whichever is
lower
</TooltipContent>
</Tooltip>
</div>
)}
{!form.formState.errors.provisionedIOPS &&
field.value > maxIopsBasedOnCompute && (
<p>
Note: Final usable IOPS will be at{' '}
<span className="text-foreground">
{maxIopsBasedOnCompute.toLocaleString()}
</span>{' '}
based on your current compute size of {currentCompute?.name}
</p>
)}
</div>
{!form.formState.errors.provisionedIOPS && (
<DiskManagementIOPSReadReplicas
isDirty={form.formState.dirtyFields.provisionedIOPS !== undefined}
oldIOPS={iops ?? 0}
newIOPS={field.value}
oldStorageType={form.formState.defaultValues?.storageType as DiskType}
newStorageType={form.getValues('storageType') as DiskType}
/>
)}
</div>
}
labelOptional="Input/output operations per second. Higher IOPS is suitable for applications requiring high throughput."
>
<div className="flex gap-3 items-center">
<div className="flex -space-x-px">
<FormControl_Shadcn_>
<Input
id="provisionedIOPS"
type="number"
className="flex-grow font-mono rounded-r-none max-w-32"
{...field}
disabled={disableInput}
onChange={(e) => {
setValue('provisionedIOPS', e.target.valueAsNumber, {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
</FormControl_Shadcn_>
<div className="border border-strong bg-surface-300 rounded-r-md px-3 flex items-center justify-center">
<span className="text-foreground-lighter text-xs font-mono">IOPS</span>
</div>
</div>
<BillingChangeBadge
show={
(watchedStorageType !== type ||
(watchedStorageType === 'gp3' && field.value !== iops)) &&
!formState.errors.provisionedIOPS
}
beforePrice={Number(iopsPrice.oldPrice)}
afterPrice={Number(iopsPrice.newPrice)}
/>
</div>
</FormItemLayout>
)}
/>
<AnimatePresence initial={false}>
{form.getValues('storageType') === 'gp3' && (
<motion.div
key="throughPutContainer"
initial={{ opacity: 0, x: -4, height: 0 }}
animate={{ opacity: 1, x: 0, height: 'auto' }}
exit={{ opacity: 0, x: -4, height: 0 }}
transition={{ duration: 0.1 }}
style={{ overflow: 'hidden' }}
>
<FormField_Shadcn_
name="throughput"
control={control}
render={({ field }) => (
<FormItemLayout
label="Throughput (MB/s)"
layout="horizontal"
description={
<div className="flex flex-col gap-y-2">
<div>
<div className="flex items-center gap-x-2">
<span>
Throughput must be between {minThroughput.toLocaleString()}{' '}
and {maxThroughput?.toLocaleString()} MB/s based on your IOPS.
</span>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-64">
Min throughput is at 125MB/s, while max throughput is at
0.25MB/s * IOPS or 1,000, whichever is lower
</TooltipContent>
</Tooltip>
</div>
{!form.formState.errors.throughput &&
field.value !== undefined &&
field.value > maxThroughputBasedOnCompute && (
<p>
Note: Final usable throughput will be at{' '}
<span className="text-foreground">
{maxThroughputBasedOnCompute.toFixed(0)}
</span>{' '}
MB/s based on your current compute size of{' '}
{currentCompute?.name}
</p>
)}
</div>
{!form.formState.errors.throughput && (
<DiskManagementThroughputReadReplicas
isDirty={form.formState.dirtyFields.throughput !== undefined}
oldThroughput={throughput_mbps ?? 0}
newThroughput={field.value ?? 0}
oldStorageType={
form.formState.defaultValues?.storageType as DiskType
}
newStorageType={form.getValues('storageType') as DiskType}
/>
)}
</div>
}
>
<div className="flex gap-3 items-center">
<div className="flex -space-x-px">
<FormControl_Shadcn_>
<Input
type="number"
{...field}
onChange={(e) => {
setValue('throughput', e.target.valueAsNumber, {
shouldDirty: true,
shouldValidate: true,
})
}}
className="flex-grow font-mono rounded-r-none max-w-32"
disabled={disableInput || watchedStorageType === 'io2'}
/>
</FormControl_Shadcn_>
<div className="border border-strong bg-surface-300 rounded-r-md px-3 flex items-center justify-center">
<span className="text-foreground-lighter text-xs font-mono">
MB/s
</span>
</div>
</div>
<BillingChangeBadge
show={
formState.isDirty &&
formState.dirtyFields.throughput &&
!formState.errors.throughput
}
beforePrice={Number(throughputPrice.oldPrice)}
afterPrice={Number(throughputPrice.newPrice)}
/>
</div>
</FormItemLayout>
)}
/>
</motion.div>
)}
</AnimatePresence>
</CardContent>
<Separator />
<CardContent className="py-10 px-8">
<FormField_Shadcn_
name="totalSize"
control={control}
render={({ field }) => (
<FormItemLayout
label="Disk Size"
layout="horizontal"
description={
<>
{includedDiskGB > 0 &&
`Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`}
{field.value < size_gb && (
<Admonition
className="mt-2"
type="default"
title="Reducing your project's disk size?"
description={
<Markdown
className="[&>p]:!leading-normal"
content={`Your disk size will automatically "right-size" when you [upgrade your project](/project/${projectRef}/settings/infrastructure).`}
/>
}
>
<DocsButton
abbrev={false}
href="https://supabase.com/docs/guides/platform/database-size#reducing-disk-size"
/>
</Admonition>
)}
</>
}
>
<div className="mt-1 relative flex gap-2 items-center">
<div className="flex -space-x-px max-w-48">
<FormControl_Shadcn_>
<Input
type="number"
step="1"
{...field}
disabled={disableInput}
className="flex-grow font-mono rounded-r-none"
onWheel={(e) => e.currentTarget.blur()}
onChange={(e) => {
setValue('totalSize', e.target.valueAsNumber, {
shouldDirty: true,
shouldValidate: true,
})
trigger('provisionedIOPS')
trigger('throughput')
}}
min={includedDiskGB}
/>
</FormControl_Shadcn_>
<div className="border border-strong bg-surface-300 rounded-r-md px-3 flex items-center justify-center">
<span className="text-foreground-lighter text-xs font-mono">GB</span>
</div>
</div>
<AnimatePresence initial={false}>
{isAllocatedStorageDirty && (
<motion.div
key="reset-disksize"
initial={{ opacity: 0, scale: 0.95, x: -2 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.95, x: -2 }}
transition={{ duration: 0.15 }}
>
<Button
htmlType="button"
type="default"
size="small"
className="px-2"
onClick={() => form.resetField('totalSize')}
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
</Button>
</motion.div>
)}
</AnimatePresence>
<BillingChangeBadge
beforePrice={Number(diskSizePrice.oldPrice)}
afterPrice={Number(diskSizePrice.newPrice)}
show={
formState.isDirty &&
!formState.errors.totalSize &&
diskSizePrice.oldPrice !== diskSizePrice.newPrice
}
/>
</div>
</FormItemLayout>
)}
/>
<div className="grid grid-cols-12 gap-10">
{/* You can add additional content in the remaining 4 columns if needed */}
<div className="col-span-4">
{/* Additional content or information can go here */}
</div>
<div className="col-span-8 space-y-6 mt-6">
<DiskSpaceBar form={form} />
<DiskManagementDiskSizeReadReplicas
isDirty={form.formState.dirtyFields.totalSize !== undefined}
totalSize={size_gb * 1.25}
usedSize={mainDiskUsed}
newTotalSize={watchedTotalSize * 1.25}
oldStorageType={form.formState.defaultValues?.storageType as DiskType}
newStorageType={form.getValues('storageType') as DiskType}
/>
</div>
</div>
</CardContent>
</Card>
{isRequestingChanges ? (
<Card className="px-2 rounded-none">
<CardContent className="py-3 flex gap-3 px-3 items-center">
<div className="flex flex-col">
<p className="text-foreground-lighter text-sm p-0">
Disk configuration changes have been requested
</p>
<p className="text-sm">
The requested changes will be applied to your disk shortly
</p>
</div>
</CardContent>
</Card>
) : (
<DiskCountdownRadial />
)}
<SpendCapDisabledSection />
<Card className="bg-surface-100 rounded-t-none">
<CardContent className="flex items-center pb-0 py-3 px-8 gap-3 justify-end">
<FormFooterChangeBadge formState={formState} />
<div className="flex gap-2">
<Button
type="default"
onClick={() => form.reset()}
disabled={!form.formState.isDirty}
>
Cancel
</Button>
<DiskManagementReviewAndSubmitDialog
loading={isUpdatingDiskConfiguration}
form={form}
numReplicas={readReplicas.length}
isDialogOpen={isDialogOpen}
disabled={disableInput}
onSubmit={onSubmit}
setIsDialogOpen={setIsDialogOpen}
buttonSize={'tiny'}
/>
</div>
</CardContent>
</Card>
</div>
</form>
</Form_Shadcn_>
<NoticeBar
visible={true}
type="default"
title="Disk Management has moved"
description="Disk configuration is now managed alongside Project Compute on the new Compute and Disk page."
actions={
<Button type="default" asChild>
<Link
href={`/project/${projectRef}/settings/compute-and-disk`}
className="!no-underline"
>
Go to Compute and Disk
</Link>
</Button>
}
/>
</div>
)
}
@@ -321,17 +321,18 @@ export const DiskManagementReviewAndSubmitDialog = ({
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
)}
{form.formState.defaultValues?.totalSize !== form.getValues('totalSize') && (
<TableDataRow
attribute="Disk size"
defaultValue={form.formState.defaultValues?.totalSize?.toLocaleString() ?? 0}
newValue={form.getValues('totalSize')?.toLocaleString()}
unit="GB"
beforePrice={Number(diskSizePrice.oldPrice)}
afterPrice={Number(diskSizePrice.newPrice)}
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
)}
{form.formState.defaultValues?.totalSize !== form.getValues('totalSize') ||
(form.formState.defaultValues?.storageType !== form.getValues('storageType') && (
<TableDataRow
attribute="Disk size"
defaultValue={form.formState.defaultValues?.totalSize?.toLocaleString() ?? 0}
newValue={form.getValues('totalSize')?.toLocaleString()}
unit="GB"
beforePrice={Number(diskSizePrice.oldPrice)}
afterPrice={Number(diskSizePrice.newPrice)}
priceTooltip={numReplicas > 0 ? replicaTooltipText : undefined}
/>
))}
{form.formState.defaultValues?.growthPercent !== form.getValues('growthPercent') && (
<TableDataRow
attribute="Growth percent"
@@ -153,7 +153,7 @@ export function ComputeSizeField({ form, disabled }: ComputeSizeFieldProps) {
type="default"
className="mt-3 border-violet-900 bg-violet-200 [&_h5]:text-violet-1100"
visible={showUpgradeBadge && form.watch('computeSize') === 'ci_nano'}
title={'Upgrade to Micro Compute at no additional charge'}
title={'Upgrade to Micro Compute'}
description="This Project is already paying for Micro Compute. You can upgrade to Micro Compute at any time when convenient."
/>
</>
@@ -43,7 +43,10 @@ export function DiskSizeField({
isLoading: isLoadingDiskAttributes,
error: diskAttributesError,
isError: isDiskAttributesError,
} = useDiskAttributesQuery({ projectRef })
} = useDiskAttributesQuery(
{ projectRef },
{ enabled: project && project.cloud_provider !== 'FLY' }
)
const {
data: subscription,
error: subscriptionError,
@@ -55,9 +58,12 @@ export function DiskSizeField({
data: diskUtil,
error: diskUtilError,
isError: isDiskUtilizationError,
} = useDiskUtilizationQuery({
projectRef: projectRef,
})
} = useDiskUtilizationQuery(
{
projectRef: projectRef,
},
{ enabled: project && project.cloud_provider !== 'FLY' }
)
const error = subscriptionError || diskUtilError || diskAttributesError
const isError = isSubscriptionError || isDiskUtilizationError || isDiskAttributesError
@@ -54,13 +54,23 @@ export function StorageTypeField({ form, disableInput }: StorageTypeFieldProps)
/**
* Set default IOPS if not dirty
* This is to ensure that the IOPS is set to the minimum value if the user has not changed it
*
* Only set it if the current configured IOPS is smaller than the min IOPS, otherwise leave at same IOPS
*/
if (e === 'gp3') {
if (!form.getFieldState('provisionedIOPS').isDirty) {
if (
!form.getFieldState('provisionedIOPS').isDirty &&
(!form.getValues('provisionedIOPS') ||
form.getValues('provisionedIOPS') < DISK_LIMITS[DiskType.GP3].minIops)
) {
form.setValue('provisionedIOPS', DISK_LIMITS[DiskType.GP3].minIops)
}
} else {
if (!form.getFieldState('provisionedIOPS').isDirty) {
if (
!form.getFieldState('provisionedIOPS').isDirty &&
(!form.getValues('provisionedIOPS') ||
form.getValues('provisionedIOPS') < DISK_LIMITS[DiskType.IO2].minIops)
) {
form.setValue('provisionedIOPS', DISK_LIMITS[DiskType.IO2].minIops)
}
}
@@ -12,9 +12,9 @@ import { calculateThroughputPrice } from '../DiskManagement.utils'
import { BillingChangeBadge } from '../ui/BillingChangeBadge'
import {
COMPUTE_BASELINE_THROUGHPUT,
DISK_LIMITS,
DiskType,
RESTRICTED_COMPUTE_FOR_IOPS_ON_GP3,
THROUGHPUT_RANGE,
} from '../ui/DiskManagement.constants'
import { DiskManagementThroughputReadReplicas } from '../ui/DiskManagementReadReplicas'
import FormMessage from '../ui/FormMessage'
@@ -53,9 +53,13 @@ export function ThroughputField({ form, disableInput }: ThroughputFieldProps) {
} else if (watchedStorageType === 'gp3') {
// Ensure throughput is within the allowed range if it's greater than or equal to 400 GB
const currentThroughput = form.getValues('throughput')
const { min, max } = THROUGHPUT_RANGE[DiskType.GP3]
if (!currentThroughput || currentThroughput < min || currentThroughput > max) {
setValue('throughput', min) // Reset to default if undefined or out of bounds
const { minThroughput, maxThroughput } = DISK_LIMITS[DiskType.GP3]
if (
!currentThroughput ||
currentThroughput < minThroughput ||
currentThroughput > maxThroughput
) {
setValue('throughput', minThroughput) // Reset to default if undefined or out of bounds
}
}
}, [watchedStorageType, watchedTotalSize, setValue, form])
@@ -15,15 +15,6 @@ export const DISK_AUTOSCALE_CONFIG_DEFAULTS = {
maxSizeGb: 60000,
}
export const IOPS_RANGE = {
[DiskType.GP3]: { min: 3000, max: 16000 },
[DiskType.IO2]: { min: 100, max: 256000 },
}
export const THROUGHPUT_RANGE = {
[DiskType.GP3]: { min: 125, max: 1000 },
}
export const DISK_PRICING = {
[DiskType.GP3]: {
storage: 0.125, // per GB per month
@@ -50,7 +41,7 @@ export const DISK_LIMITS = {
[DiskType.IO2]: {
minStorage: 4,
maxStorage: 61440,
minIops: 100,
minIops: 1500,
maxIops: 256000,
includedIops: 0,
includedThroughput: 0,
@@ -33,8 +33,8 @@ export const DiskManagementDiskSizeReadReplicas = ({
const { data: databases } = useReadReplicasQuery({ projectRef })
const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef)
const beforePrice = totalSize * DISK_PRICING[oldStorageType]?.storage ?? 0
const afterPrice = newTotalSize * DISK_PRICING[newStorageType]?.storage ?? 0
const beforePrice = totalSize * DISK_PRICING[oldStorageType]?.storage
const afterPrice = newTotalSize * DISK_PRICING[newStorageType]?.storage
if (readReplicas.length === 0) return null
@@ -146,9 +146,9 @@ export const DiskManagementIOPSReadReplicas = ({
const readReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef)
const beforePrice =
(oldIOPS - DISK_LIMITS[oldStorageType]?.includedIops) * DISK_PRICING[oldStorageType]?.iops ?? 0
(oldIOPS - DISK_LIMITS[oldStorageType]?.includedIops) * DISK_PRICING[oldStorageType]?.iops
const afterPrice =
(newIOPS - DISK_LIMITS[newStorageType]?.includedIops) * DISK_PRICING[newStorageType]?.iops ?? 0
(newIOPS - DISK_LIMITS[newStorageType]?.includedIops) * DISK_PRICING[newStorageType]?.iops
if (readReplicas.length === 0) return null
@@ -205,12 +205,12 @@ export const DiskManagementThroughputReadReplicas = ({
const beforePrice =
oldStorageType === DiskType.GP3
? (oldThroughput - DISK_LIMITS[oldStorageType].includedThroughput) *
DISK_PRICING[oldStorageType]?.throughput ?? 0
DISK_PRICING[oldStorageType]?.throughput
: 0
const afterPrice =
newStorageType === DiskType.GP3
? (newThroughput - DISK_LIMITS[newStorageType].includedThroughput) *
DISK_PRICING[newStorageType]?.throughput ?? 0
DISK_PRICING[newStorageType]?.throughput
: 0
if (readReplicas.length === 0) return null
@@ -40,7 +40,6 @@ import { getDatabaseMajorVersion, getSemanticVersion } from 'lib/helpers'
import { useAddonsPagePanel } from 'state/addons-page'
import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
import ComputeInstanceSidePanel from './ComputeInstanceSidePanel'
import CustomDomainSidePanel from './CustomDomainSidePanel'
import IPv4SidePanel from './IPv4SidePanel'
import PITRSidePanel from './PITRSidePanel'
@@ -61,7 +60,6 @@ const Addons = () => {
const computeSizeChangesDisabled = useFlag('disableComputeSizeChanges')
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
const diskAndComputeFormEnabled = useFlag('diskAndComputeForm')
const hasHipaaAddon = subscriptionHasHipaaAddon(subscription)
@@ -234,38 +232,19 @@ const Addons = () => {
</div>
)}
{!diskAndComputeFormEnabled ? (
<ProjectUpdateDisabledTooltip
projectUpdateDisabled={projectUpdateDisabled || computeSizeChangesDisabled}
projectNotActive={!isProjectActive}
tooltip="Compute size changes are currently disabled. Our engineers are working on a fix."
>
<Button
type="default"
className="mt-2 pointer-events-auto"
onClick={() => setPanel('computeInstance')}
disabled={
!isProjectActive || projectUpdateDisabled || computeSizeChangesDisabled
}
>
Change compute size
<NoticeBar
visible={true}
type="default"
title="Compute size has moved"
description="Compute size is now managed alongside Disk configuration on the new Compute and Disk page."
actions={
<Button type="default" asChild>
<Link href={`/project/${projectRef}/settings/compute-and-disk`}>
Go to Compute and Disk
</Link>
</Button>
</ProjectUpdateDisabledTooltip>
) : (
<NoticeBar
visible={true}
type="default"
title="Compute size has moved"
description="Compute size is now managed alongside Disk configuration on the new Compute and Disk page."
actions={
<Button type="default" asChild>
<Link href={`/project/${projectRef}/settings/compute-and-disk`}>
Go to Compute and Disk
</Link>
</Button>
}
/>
)}
}
/>
{Number(mostRecentRemainingIOBudget?.disk_io_budget) === 0 ? (
<Alert
@@ -344,44 +323,6 @@ const Addons = () => {
<p className="text-sm text-foreground-light">No. of pooler connections</p>
<p className="text-sm">{meta?.connections_pooler ?? '-'}</p>
</div>
{!diskAndComputeFormEnabled && (
<>
<div className="w-full flex items-center justify-between border-b py-2">
<Link href={`/project/${projectRef}/settings/infrastructure#disk_io`}>
<div className="group flex items-center space-x-2">
<p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer">
Max Disk Throughput
</p>
<ChevronRight
strokeWidth={1.5}
size={16}
className="transition opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0"
/>
</div>
</Link>
<p className="text-sm">
{meta?.max_disk_io_mbs?.toLocaleString() ?? '-'} Mbps
</p>
</div>
<div className="w-full flex items-center justify-between py-2">
<Link href={`/project/${projectRef}/settings/infrastructure#disk_io`}>
<div className="group flex items-center space-x-2">
<p className="text-sm text-foreground-light group-hover:text-foreground transition cursor-pointer">
Baseline Disk Throughput
</p>
<ChevronRight
strokeWidth={1.5}
size={16}
className="transition opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0"
/>
</div>
</Link>
<p className="text-sm">
{meta?.baseline_disk_io_mbs?.toLocaleString() ?? '-'} Mbps
</p>
</div>
</>
)}
</div>
</div>
</ScaffoldSectionContent>
@@ -658,7 +599,6 @@ const Addons = () => {
</>
)}
<ComputeInstanceSidePanel />
<PITRSidePanel />
<CustomDomainSidePanel />
<IPv4SidePanel />
@@ -1,477 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useQueryClient } from '@tanstack/react-query'
import { ExternalLink, Info } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
useIsProjectActive,
useProjectContext,
} from 'components/layouts/ProjectLayout/ProjectContext'
import { setProjectStatus } from 'data/projects/projects-query'
import { MAX_REPLICAS_BELOW_XL, useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonRemoveMutation } from 'data/subscriptions/project-addon-remove-mutation'
import { useProjectAddonUpdateMutation } from 'data/subscriptions/project-addon-update-mutation'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import type { AddonVariantId, ProjectAddonVariantMeta } from 'data/subscriptions/types'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { INSTANCE_MICRO_SPECS, PROJECT_STATUS } from 'lib/constants'
import { useAddonsPagePanel } from 'state/addons-page'
import {
Alert,
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Badge,
Button,
Modal,
Radio,
SidePanel,
Tooltip,
TooltipContent,
TooltipTrigger,
WarningIcon,
} from 'ui'
const ComputeInstanceSidePanel = () => {
const queryClient = useQueryClient()
const router = useRouter()
const { ref: projectRef } = useParams()
const { project: selectedProject } = useProjectContext()
const organization = useSelectedOrganization()
const computeSizeChangesDisabled = useFlag('disableComputeSizeChanges')
const diskAndComputeForm = useFlag('diskAndComputeForm')
const [showConfirmationModal, setShowConfirmationModal] = useState(false)
const canUpdateCompute = useCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions'
)
const isProjectActive = useIsProjectActive()
const { panel, setPanel, closePanel } = useAddonsPagePanel()
const visible = panel === 'computeInstance'
const { data: databases } = useReadReplicasQuery({ projectRef }, { enabled: visible })
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef }, { enabled: visible })
const { data: subscription } = useOrgSubscriptionQuery(
{ orgSlug: organization?.slug },
{ enabled: visible }
)
const { mutate: updateAddon, isLoading: isUpdating } = useProjectAddonUpdateMutation({
onSuccess: () => {
toast.success(
`Successfully updated compute instance to ${selectedCompute?.name}. Your project is currently being restarted to update its instance`,
{ duration: 8000 }
)
setProjectStatus(queryClient, projectRef!, PROJECT_STATUS.RESIZING)
closePanel()
router.push(`/project/${projectRef}`)
},
onError: (error) => {
toast.error(`Unable to update compute instance: ${error.message}`)
},
})
const { mutate: removeAddon, isLoading: isRemoving } = useProjectAddonRemoveMutation({
onSuccess: () => {
toast.success(
`Successfully updated compute instance. Your project is currently being restarted to update its instance`,
{ duration: 8000 }
)
setProjectStatus(queryClient, projectRef!, PROJECT_STATUS.RESIZING)
closePanel()
router.push(`/project/${projectRef}`)
},
onError: (error) => {
toast.error(`Unable to update compute instance: ${error.message}`)
},
})
const [selectedOption, setSelectedOption] = useState<string>('')
const isSubmitting = isUpdating || isRemoving
const projectId = selectedProject?.id
const cpuArchitecture = getCloudProviderArchitecture(selectedProject?.cloud_provider)
const selectedAddons = addons?.selected_addons ?? []
const availableAddons = useMemo(() => {
return addons?.available_addons ?? []
}, [addons])
const isFreePlan = subscription?.plan?.id === 'free'
const subscriptionCompute = selectedAddons.find((addon) => addon.type === 'compute_instance')
const pitrAddon = selectedAddons.find((addon) => addon.type === 'pitr')
const hasMicroOptionFromApi = useMemo(() => {
return (
availableAddons.find((addon) => addon.type === 'compute_instance')?.variants ?? []
).some((variant) => variant.identifier === 'ci_micro')
}, [availableAddons])
const availableOptions = useMemo(() => {
const computeOptions =
availableAddons
.find((addon) => addon.type === 'compute_instance')
?.variants.filter((option) => {
if (!selectedProject?.cloud_provider) {
return true
}
const meta = option.meta as ProjectAddonVariantMeta
return (
!meta.supported_cloud_providers ||
meta.supported_cloud_providers.includes(selectedProject.cloud_provider)
)
}) ?? []
// Backwards comp until API is deployed
if (!hasMicroOptionFromApi) {
// Unshift to push to start of array
computeOptions.unshift({
identifier: 'ci_micro',
name: 'Micro',
price_description: '$0.01344/hour (~$10/month)',
price: 0.01344,
price_interval: 'hourly',
price_type: 'usage',
// @ts-ignore API types it as Record<string, never>
meta: {
cpu_cores: INSTANCE_MICRO_SPECS.cpu_cores,
cpu_dedicated: INSTANCE_MICRO_SPECS.cpu_dedicated,
memory_gb: INSTANCE_MICRO_SPECS.memory_gb,
baseline_disk_io_mbs: INSTANCE_MICRO_SPECS.baseline_disk_io_mbs,
max_disk_io_mbs: INSTANCE_MICRO_SPECS.max_disk_io_mbs,
connections_direct: INSTANCE_MICRO_SPECS.connections_direct,
connections_pooler: INSTANCE_MICRO_SPECS.connections_pooler,
} as ProjectAddonVariantMeta,
})
}
return computeOptions
}, [availableAddons, hasMicroOptionFromApi])
const defaultInstanceSize = useMemo(() => {
if (!selectedProject) return ''
return selectedProject.infra_compute_size === 'nano' ? 'ci_nano' : 'ci_micro'
}, [selectedProject])
const selectedCompute = availableOptions.find((option) => option.identifier === selectedOption)
const hasChanges =
selectedOption !== (subscriptionCompute?.variant.identifier ?? defaultInstanceSize)
const numReadReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef).length
const hasReadReplicas = numReadReplicas > 0
const isDowngradingToBelowSmall = ['ci_micro', 'ci_nano'].includes(selectedOption)
const blockDowngradeDueToPitr = pitrAddon !== undefined && isDowngradingToBelowSmall && hasChanges
const hasMoreThanTwoReplicasForXLAndAbove =
numReadReplicas > MAX_REPLICAS_BELOW_XL &&
['ci_micro', 'ci_small', 'ci_medium', 'ci_large'].includes(selectedOption)
const blockDowngradeDueToReadReplicas =
hasChanges &&
hasReadReplicas &&
(isDowngradingToBelowSmall || hasMoreThanTwoReplicasForXLAndAbove)
const onConfirmUpdateComputeInstance = async () => {
if (!projectRef) return console.error('Project ref is required')
if (!projectId) return console.error('Project ID is required')
if (computeSizeChangesDisabled)
toast.error(
'Compute size changes are currently disabled - our engineers are working on a fix.'
)
// Temporary backwards compatibility fix
if (
!hasMicroOptionFromApi &&
selectedOption === 'ci_micro' &&
subscriptionCompute !== undefined
) {
removeAddon({ projectRef, variant: subscriptionCompute.variant.identifier })
} else {
updateAddon({
projectRef,
type: 'compute_instance',
variant: selectedOption as AddonVariantId,
})
}
}
useEffect(() => {
if (visible) {
if (subscriptionCompute !== undefined) {
setSelectedOption(subscriptionCompute.variant.identifier)
} else {
setSelectedOption(defaultInstanceSize)
}
}
}, [visible, isLoading])
useEffect(() => {
if (visible && diskAndComputeForm) {
router.push(`/project/${projectRef}/settings/compute-and-disk`)
}
}, [visible, diskAndComputeForm, router, projectRef])
return (
<>
<SidePanel
size="xxlarge"
visible={visible}
onCancel={closePanel}
onConfirm={() => setShowConfirmationModal(true)}
loading={isLoading}
disabled={
isFreePlan ||
isLoading ||
!hasChanges ||
blockDowngradeDueToPitr ||
blockDowngradeDueToReadReplicas ||
!canUpdateCompute ||
!isProjectActive
}
tooltip={
isFreePlan
? 'Unable to update compute instance on a Free Plan'
: !isProjectActive
? 'Unable to update compute as project is currently not active'
: !canUpdateCompute
? 'You do not have permission to update compute instance'
: undefined
}
header={
<div className="flex items-center justify-between">
<h4>Change project compute size</h4>
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/compute-add-ons"
target="_blank"
rel="noreferrer"
>
About compute sizes
</Link>
</Button>
</div>
}
>
<SidePanel.Content>
<div className="py-6 space-y-4">
{selectedProject?.infra_compute_size === 'nano' && subscription?.plan.id !== 'free' && (
<Alert_Shadcn_ variant="default">
<Info strokeWidth={2} />
<AlertTitle_Shadcn_>Free compute upgrade to Micro</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Paid Plans include a free upgrade to Micro compute. Your project is ready to
upgrade for no additional charges.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<p className="text-sm">
For the database, compute size refers to the amount of CPU and memory allocated to the
database instance.
</p>
<div className="pb-4">
{isFreePlan && (
<Alert
withIcon
className="mb-4"
variant="info"
title="Changing your compute size is only available on the Pro Plan"
actions={
<Button asChild type="default">
<Link
href={`/org/${organization?.slug}/billing?panel=subscriptionPlan&source=computeInstanceSidePanel`}
>
View available plans
</Link>
</Button>
}
>
Upgrade your plan to change the compute size of your project
</Alert>
)}
<Radio.Group
type="large-cards"
size="tiny"
id="compute-instance"
label={<p className="text-sm">Choose the compute size you want to use</p>}
onChange={(event: any) => setSelectedOption(event.target.value)}
>
{availableOptions.map((option) => (
<Radio
className="col-span-3 !p-0"
disabled={isFreePlan}
name="compute-instance"
key={option.identifier}
checked={selectedOption === option.identifier}
label={option.name}
value={option.identifier}
>
<div className="w-full group">
<div className="border-b border-default px-4 py-2">
<p className="text-sm flex justify-between">
{option.name}{' '}
{(subscriptionCompute?.variant.identifier === option.identifier ||
(!subscriptionCompute &&
option.identifier ===
`ci_${selectedProject?.infra_compute_size}`)) && (
<Badge>Current</Badge>
)}
{selectedProject?.infra_compute_size === 'nano' &&
option.identifier === 'ci_micro' && <Badge>Free Upgrade</Badge>}
</p>
</div>
<div className="px-4 py-2">
<p className="text-foreground-light">
{option.meta?.memory_gb ?? 0} GB memory
</p>
<p className="text-foreground-light">
{option.meta?.cpu_cores ?? 0}-core {cpuArchitecture} CPU (
{option.meta?.cpu_dedicated ? 'Dedicated' : 'Shared'})
</p>
<div className="flex justify-between items-center mt-2">
<div className="flex items-center space-x-1">
<span className="text-foreground text-sm">
{/* Price needs to be exact here */}${option.price}
</span>
<span className="text-foreground-light translate-y-[1px]">
{' '}
/ {option.price_interval === 'monthly' ? 'month' : 'hour'}
</span>
</div>
{option.price_interval === 'hourly' && (
<Tooltip>
<TooltipTrigger>
<Info
size={14}
strokeWidth={2}
className="hover:text-foreground-light"
/>
</TooltipTrigger>
<TooltipContent side="bottom">
${Number(option.price * 672).toFixed(0)} - $
{Number(option.price * 744).toFixed(0)} per month
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</div>
</Radio>
))}
</Radio.Group>
</div>
{hasChanges && (
<p className="text-sm text-foreground-light">
There are no immediate charges when changing compute. Compute Hours are a
usage-based item and you're billed at the end of your billing cycle based on your
compute usage. Read more about{' '}
<Link
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
className="underline"
>
Compute Billing
</Link>
.
</p>
)}
{hasChanges && !blockDowngradeDueToPitr && !blockDowngradeDueToReadReplicas && (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
Your project will need to be restarted when changing its compute size
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Your project will be unavailable for up to 2 minutes while the changes take place.
</AlertDescription_Shadcn_>
{hasReadReplicas && (
<AlertDescription_Shadcn_>
The compute sizes for <span className="text-foreground">all read replicas</span>{' '}
on your project will also be changed to the {selectedCompute?.name} size as
well.
</AlertDescription_Shadcn_>
)}
</Alert_Shadcn_>
)}
{blockDowngradeDueToReadReplicas ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>
{hasMoreThanTwoReplicasForXLAndAbove && selectedOption !== 'ci_micro'
? `Unable to downgrade as project has more than ${MAX_REPLICAS_BELOW_XL} read replicas`
: 'Unable to downgrade as project has active read replicas'}
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{hasMoreThanTwoReplicasForXLAndAbove && selectedOption !== 'ci_micro'
? `You can only have up to ${MAX_REPLICAS_BELOW_XL} read replicas for compute sizes below XL, as such you will need to remove at least ${numReadReplicas - MAX_REPLICAS_BELOW_XL} read replica${numReadReplicas - MAX_REPLICAS_BELOW_XL > 1 ? 's' : ''} before downgrading your compute.`
: 'The minimum compute size for using read replicas is the Small Compute. You need to remove all read replicas before downgrading Compute as it requires at least a Small compute instance.'}
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-2">
<Button asChild type="default">
<Link
href={`/project/${projectRef}/settings/infrastructure`}
onClick={closePanel}
>
Manage read replicas
</Link>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : blockDowngradeDueToPitr ? (
<Alert_Shadcn_>
<WarningIcon />
<AlertTitle_Shadcn_>Disable PITR before downgrading</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You currently have PITR enabled. The minimum compute size for using PITR is the
Small Compute.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_>
You need to disable PITR before downgrading Compute as it requires at least a
Small compute instance.
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-2">
<Button type="default" onClick={() => setPanel('pitr')}>
Change PITR
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : null}
</div>
</SidePanel.Content>
</SidePanel>
<Modal
size="medium"
alignFooter="right"
visible={showConfirmationModal}
loading={isSubmitting}
onCancel={() => setShowConfirmationModal(false)}
onConfirm={() => onConfirmUpdateComputeInstance()}
header="Confirm to upgrade compute instance now"
>
<Modal.Content>
<Alert
withIcon
variant="warning"
title="Your project will need to be restarted when changing its compute size"
>
Your project will be unavailable for up to 2 minutes while the changes take place.
</Alert>
</Modal.Content>
</Modal>
</>
)
}
export default ComputeInstanceSidePanel
@@ -1,4 +1,5 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ExternalLink } from 'lucide-react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useEffect, useState } from 'react'
@@ -30,7 +31,6 @@ import {
WarningIcon,
cn,
} from 'ui'
import { ExternalLink, AlertTriangle } from 'lucide-react'
const PITR_CATEGORY_OPTIONS: {
id: 'off' | 'on'
@@ -296,12 +296,10 @@ const PITRSidePanel = () => {
className="mb-4"
title="Your project is required to minimally be on a Small compute size to enable PITR"
actions={[
<Button
key="change-compute"
type="default"
onClick={() => setPanel('computeInstance')}
>
Change compute size
<Button asChild key="change-compute" type="default">
<Link href={`/project/${projectRef}/settings/compute-and-disk`}>
Change compute size
</Link>
</Button>,
]}
>
@@ -27,7 +27,6 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { AWS_REGIONS_DEFAULT, BASE_PATH } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import type { AWS_REGIONS_KEYS } from 'shared-data'
@@ -71,7 +70,6 @@ const DeployNewReplicaPanel = ({
const { ref: projectRef } = useParams()
const project = useSelectedProject()
const org = useSelectedOrganization()
const diskAndComputeFormEnabled = useFlag('diskAndComputeForm')
const { data } = useReadReplicasQuery({ projectRef })
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
@@ -302,9 +300,7 @@ const DeployNewReplicaPanel = ({
href={
isFreePlan
? `/org/${org?.slug}/billing?panel=subscriptionPlan&source=deployNewReplicaPanelSmallCompute`
: diskAndComputeFormEnabled
? `/project/${projectRef}/settings/compute-and-disk`
: `/project/${projectRef}/settings/addons?panel=computeInstance`
: `/project/${projectRef}/settings/compute-and-disk`
}
>
{isFreePlan ? 'Upgrade to Pro' : 'Change compute size'}
@@ -401,9 +397,7 @@ const DeployNewReplicaPanel = ({
href={
isFreePlan
? `/org/${org?.slug}/billing?panel=subscriptionPlan&source=deployNewReplicaPanelMaxReplicas`
: diskAndComputeFormEnabled
? `/project/${projectRef}/settings/compute-and-disk`
: `/project/${projectRef}/settings/addons?panel=computeInstance`
: `/project/${projectRef}/settings/compute-and-disk`
}
>
Upgrade compute size
@@ -17,9 +17,10 @@ import {
useReadReplicasStatusesQuery,
} from 'data/read-replicas/replicas-status-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import { timeout } from 'lib/helpers'
import Link from 'next/link'
import { type AWS_REGIONS_KEYS } from 'shared-data'
import { useAddonsPagePanel } from 'state/addons-page'
import {
Button,
DropdownMenu,
@@ -29,7 +30,6 @@ import {
DropdownMenuTrigger,
cn,
} from 'ui'
import ComputeInstanceSidePanel from '../../Addons/ComputeInstanceSidePanel'
import DeployNewReplicaPanel from './DeployNewReplicaPanel'
import DropAllReplicasConfirmationModal from './DropAllReplicasConfirmationModal'
import DropReplicaConfirmationModal from './DropReplicaConfirmationModal'
@@ -39,7 +39,6 @@ import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceCo
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
import MapView from './MapView'
import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
const InstanceConfigurationUI = () => {
const reactFlow = useReactFlow()
@@ -48,7 +47,6 @@ const InstanceConfigurationUI = () => {
const { ref: projectRef } = useParams()
const numTransition = useRef<number>()
const { project, isLoading: isLoadingProject } = useProjectContext()
const { setPanel } = useAddonsPagePanel()
const [view, setView] = useState<'flow' | 'map'>('flow')
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false)
@@ -253,8 +251,10 @@ const InstanceConfigurationUI = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 *:space-x-2">
<DropdownMenuItem onClick={() => setPanel('computeInstance')}>
<div>Resize databases</div>
<DropdownMenuItem asChild>
<Link href={`/project/${projectRef}/settings/compute-and-disk`}>
Resize databases
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setShowDeleteAllModal(true)}>
@@ -346,8 +346,6 @@ const InstanceConfigurationUI = () => {
onSuccess={() => setRefetchInterval(5000)}
onCancel={() => setSelectedReplicaToRestart(undefined)}
/>
<ComputeInstanceSidePanel />
</div>
)
}
@@ -46,7 +46,6 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
'billing:invoices',
])
const diskAndComputeEnabled = useFlag('diskAndComputeForm')
const newApiKeys = useFlag('newApiKeys')
const menuRoutes = generateSettingsMenu(ref, project, organization, {
@@ -54,7 +53,6 @@ const SettingsLayout = ({ title, children }: PropsWithChildren<SettingsLayoutPro
edgeFunctions: edgeFunctionsEnabled,
storage: storageEnabled,
invoices: invoicesEnabled,
diskAndCompute: diskAndComputeEnabled,
newApiKeys,
})
@@ -14,7 +14,6 @@ export const generateSettingsMenu = (
edgeFunctions?: boolean
storage?: boolean
invoices?: boolean
diskAndCompute?: boolean
newApiKeys?: boolean
}
): ProductMenuGroup[] => {
@@ -24,7 +23,6 @@ export const generateSettingsMenu = (
const authEnabled = features?.auth ?? true
const edgeFunctionsEnabled = features?.edgeFunctions ?? true
const storageEnabled = features?.storage ?? true
const newDiskComputeEnabled = features?.diskAndCompute ?? false
return [
{
@@ -36,7 +34,7 @@ export const generateSettingsMenu = (
url: `/project/${ref}/settings/general`,
items: [],
},
...(IS_PLATFORM && newDiskComputeEnabled
...(IS_PLATFORM
? [
{
name: 'Compute and Disk',
@@ -1,5 +1,5 @@
import { useRouter } from 'next/router'
import { MouseEvent, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { components } from 'api-types'
import { getAddons } from 'components/interfaces/Billing/Subscription/Subscription.utils'
@@ -11,7 +11,6 @@ import { INSTANCE_MICRO_SPECS } from 'lib/constants'
import { Button, HoverCard, HoverCardContent, HoverCardTrigger, Separator } from 'ui'
import { ComputeBadge } from 'ui-patterns/ComputeBadge'
import ShimmeringLoader from './ShimmeringLoader'
import { useFlag } from 'hooks/ui/useFlag'
const Row = ({ label, stat }: { label: string; stat: React.ReactNode | string }) => {
return (
@@ -32,13 +31,10 @@ interface ComputeBadgeWrapperProps {
}
export const ComputeBadgeWrapper = ({ project }: ComputeBadgeWrapperProps) => {
const router = useRouter()
// handles the state of the hover card
// once open it will fetch the addons
const [open, setOpenState] = useState(false)
const diskAndComputeFormEnabled = useFlag('diskAndComputeForm')
// returns hardcoded values for infra
const cpuArchitecture = getCloudProviderArchitecture(project.cloud_provider)
@@ -64,19 +60,6 @@ export const ComputeBadgeWrapper = ({ project }: ComputeBadgeWrapperProps) => {
(addon) => addon.name === 'Compute Instance'
)?.variants
const navigateToAddons = (e: MouseEvent<HTMLButtonElement, globalThis.MouseEvent>) => {
// These are required as the button is inside an a tag
// side note: not the best idea to nest clickables 🥲
e.preventDefault()
e.stopPropagation()
if (diskAndComputeFormEnabled) {
router.push(`/project/${project?.ref}/settings/compute-and-disk`)
} else {
router.push(`/project/${project?.ref}/settings/addons?panel=computeInstance`)
}
}
const highestComputeAvailable = availableCompute?.[availableCompute.length - 1].identifier
const isHighestCompute =
@@ -95,77 +78,82 @@ export const ComputeBadgeWrapper = ({ project }: ComputeBadgeWrapperProps) => {
if (!project?.infra_compute_size) return null
return (
<>
<HoverCard onOpenChange={() => setOpenState(!open)} openDelay={280}>
<HoverCardTrigger className="group" asChild>
<button onClick={navigateToAddons} type="button" role="button">
<ComputeBadge infraComputeSize={project.infra_compute_size} />
</button>
</HoverCardTrigger>
<HoverCardContent side="bottom" align="start" className="p-0 overflow-hidden w-96">
<div className="p-2 px-5 text-xs text-foreground-lighter">Compute size</div>
<Separator />
<div className="p-3 px-5 flex flex-row gap-4">
<div>
<ComputeBadge infraComputeSize={project?.infra_compute_size} />
</div>
<div className="flex flex-col gap-4">
{isLoading ? (
<>
<div className="flex flex-col gap-1">
<ShimmeringLoader className="h-[20px] py-0 w-32" />
<ShimmeringLoader className="h-[20px] py-0 w-32" />
</div>
</>
) : (
<>
<div className="flex flex-col gap-1">
{meta !== undefined ? (
<>
<Row
label="CPU"
stat={`${meta.cpu_cores ?? '?'}-core ${cpuArchitecture} ${meta.cpu_dedicated ? '(Dedicated)' : '(Shared)'}`}
/>
<Row label="Memory" stat={`${meta.memory_gb ?? '-'} GB`} />
</>
) : (
<>
{/* meta is only undefined for nano sized compute */}
<Row label="CPU" stat="Shared" />
<Row label="Memory" stat="Up to 0.5 GB" />
</>
)}
</div>
</>
)}
</div>
<HoverCard onOpenChange={() => setOpenState(!open)} openDelay={280}>
<HoverCardTrigger asChild className="group" onClick={(e) => e.stopPropagation()}>
<Link href={`/project/${project?.ref}/settings/compute-and-disk`}>
<ComputeBadge infraComputeSize={project.infra_compute_size} />
</Link>
</HoverCardTrigger>
<HoverCardContent
side="bottom"
align="start"
className="p-0 overflow-hidden w-96"
onClick={(e) => e.stopPropagation()}
>
<div className="p-2 px-5 text-xs text-foreground-lighter">Compute size</div>
<Separator />
<div className="p-3 px-5 flex flex-row gap-4">
<div>
<ComputeBadge infraComputeSize={project?.infra_compute_size} />
</div>
{(!isHighestCompute || isEligibleForFreeUpgrade) && (
<>
<Separator />
<div className="p-3 px-5 text-sm flex flex-col gap-2 bg-studio">
<div className="flex flex-col gap-0">
<p className="text-foreground">
{isEligibleForFreeUpgrade
? 'Free upgrade to Micro available'
: 'Unlock more compute'}
</p>
<p className="text-foreground-light">
{isEligibleForFreeUpgrade
? 'Paid plans include a free upgrade to Micro compute.'
: 'Scale your project up to 64 cores and 256 GB RAM.'}
</p>
<div className="flex flex-col gap-4">
{isLoading ? (
<>
<div className="flex flex-col gap-1">
<ShimmeringLoader className="h-[20px] py-0 w-32" />
<ShimmeringLoader className="h-[20px] py-0 w-32" />
</div>
<div>
<Button type="default" onClick={navigateToAddons} htmlType="button" role="button">
Upgrade compute
</Button>
</>
) : (
<>
<div className="flex flex-col gap-1">
{meta !== undefined ? (
<>
<Row
label="CPU"
stat={`${meta.cpu_cores ?? '?'}-core ${cpuArchitecture} ${meta.cpu_dedicated ? '(Dedicated)' : '(Shared)'}`}
/>
<Row label="Memory" stat={`${meta.memory_gb ?? '-'} GB`} />
</>
) : (
<>
{/* meta is only undefined for nano sized compute */}
<Row label="CPU" stat="Shared" />
<Row label="Memory" stat="Up to 0.5 GB" />
</>
)}
</div>
</>
)}
</div>
</div>
{(!isHighestCompute || isEligibleForFreeUpgrade) && (
<>
<Separator />
<div className="p-3 px-5 text-sm flex flex-col gap-2 bg-studio">
<div className="flex flex-col gap-0">
<p className="text-foreground">
{isEligibleForFreeUpgrade
? 'Free upgrade to Micro available'
: 'Unlock more compute'}
</p>
<p className="text-foreground-light">
{isEligibleForFreeUpgrade
? 'Paid plans include a free upgrade to Micro compute.'
: 'Scale your project up to 64 cores and 256 GB RAM.'}
</p>
</div>
</>
)}
</HoverCardContent>
</HoverCard>
</>
<div>
<Button asChild type="default" htmlType="button" role="button">
<Link href={`/project/${project?.ref}/settings/compute-and-disk`}>
Upgrade compute
</Link>
</Button>
</div>
</div>
</>
)}
</HoverCardContent>
</HoverCard>
)
}
+1 -1
View File
@@ -14,7 +14,7 @@ interface UpgradeToProProps {
icon?: ReactNode
primaryText: string
secondaryText: string
addon?: 'pitr' | 'customDomain' | 'computeInstance'
addon?: 'pitr' | 'customDomain'
buttonText?: string
source?: string
disabled?: boolean
@@ -47,10 +47,15 @@ export const useDiskAttributesQuery = <TData = DiskAttributesData>(
export const useRemainingDurationForDiskAttributeUpdate = ({
projectRef,
enabled = true,
}: {
projectRef?: string
enabled?: boolean
}) => {
const { data, isLoading, isError, isSuccess, error } = useDiskAttributesQuery({ projectRef })
const { data, isLoading, isError, isSuccess, error } = useDiskAttributesQuery(
{ projectRef },
{ enabled }
)
const lastModifiedAtString = dayjs(data?.last_modified_at ?? '').utc()
const secondsFromNow = Math.max(
-7
View File
@@ -2,7 +2,6 @@ import * as Sentry from '@sentry/nextjs'
import { useFeatureFlags } from 'common'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { trackFeatureFlag } from 'lib/posthog'
const isObjectEmpty = (obj: Object) => {
@@ -10,16 +9,10 @@ const isObjectEmpty = (obj: Object) => {
}
export function useFlag<T = boolean>(name: string) {
const project = useSelectedProject()
const flagStore = useFeatureFlags()
const store = flagStore.configcat
// Temporary override as Fly projects are cant seem to upgrade their compute with the new disk UI
if (name === 'diskAndComputeForm' && project?.cloud_provider === 'FLY') {
return false
}
if (!isObjectEmpty(store) && store[name] === undefined) {
console.error(`Flag key "${name}" does not exist in ConfigCat flag store`)
return false
+1 -1
View File
@@ -280,7 +280,7 @@ const nextConfig = {
value: 'computeInstance',
},
],
destination: '/project/:ref/settings/addons?panel=computeInstance',
destination: '/project/:ref/settings/compute-and-disk',
permanent: true,
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
import { useUrlState } from 'hooks/ui/useUrlState'
export const ADDONS_PANEL_KEYS_ARRAY = ['computeInstance', 'pitr', 'customDomain', 'ipv4'] as const
export const ADDONS_PANEL_KEYS_ARRAY = ['pitr', 'customDomain', 'ipv4'] as const
export type ADDONS_PANEL_KEYS = undefined | (typeof ADDONS_PANEL_KEYS_ARRAY)[number]