mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore: rework disk validation and cleanup (#34533)
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
+12
-11
@@ -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>,
|
||||
]}
|
||||
>
|
||||
|
||||
+2
-8
@@ -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
|
||||
|
||||
+6
-8
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
|
||||
Reference in new issue
Block a user