mirror of
https://github.com/supabase/supabase.git
synced 2026-10-07 10:25:06 +03:00
806 lines
36 KiB
TypeScript
806 lines
36 KiB
TypeScript
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 { 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">
|
|
<FormHeader
|
|
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_>
|
|
</div>
|
|
)
|
|
}
|