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(false) const [remainingTime, setRemainingTime] = useState(0) const [refetchInterval, setRefetchInterval] = useState(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({ 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 (
Go to Compute and Disk } />
) } if (planId === 'free') { return (
Disk size configuration is not available for projects on the Free Plan

If you are intending to use more than 500MB of disk space, then you will need to upgrade to at least the Pro Plan.

) } return (
( { 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} >
General Purpose SSD{' '}
gp3

gp3 provides a balance between price and performance

} />
Provisioned IOPS SSD{' '}
io2

io2 offers high IOPS for mission-critical applications.

} /> )} /> (
{watchedStorageType === 'io2' ? (
IOPS must be{' '} {watchedTotalSize >= 8 ? `between ${minIOPS} and ${maxIOPS.toLocaleString()} based on your disk size.` : `at least ${minIOPS}`} 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
) : (
IOPS must be{' '} {watchedTotalSize >= 8 ? `between ${minIOPS.toLocaleString()} and ${maxIOPS.toLocaleString()} based on your disk size.` : `at least ${minIOPS.toLocaleString()}`} 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
)} {!form.formState.errors.provisionedIOPS && field.value > maxIopsBasedOnCompute && (

Note: Final usable IOPS will be at{' '} {maxIopsBasedOnCompute.toLocaleString()} {' '} based on your current compute size of {currentCompute?.name}

)}
{!form.formState.errors.provisionedIOPS && ( )} } labelOptional="Input/output operations per second. Higher IOPS is suitable for applications requiring high throughput." >
{ setValue('provisionedIOPS', e.target.valueAsNumber, { shouldDirty: true, shouldValidate: true, }) }} />
IOPS
)} /> {form.getValues('storageType') === 'gp3' && ( (
Throughput must be between {minThroughput.toLocaleString()}{' '} and {maxThroughput?.toLocaleString()} MB/s based on your IOPS. Min throughput is at 125MB/s, while max throughput is at 0.25MB/s * IOPS or 1,000, whichever is lower
{!form.formState.errors.throughput && field.value !== undefined && field.value > maxThroughputBasedOnCompute && (

Note: Final usable throughput will be at{' '} {maxThroughputBasedOnCompute.toFixed(0)} {' '} MB/s based on your current compute size of{' '} {currentCompute?.name}

)}
{!form.formState.errors.throughput && ( )} } >
{ setValue('throughput', e.target.valueAsNumber, { shouldDirty: true, shouldValidate: true, }) }} className="flex-grow font-mono rounded-r-none max-w-32" disabled={disableInput || watchedStorageType === 'io2'} />
MB/s
)} />
)}
( {includedDiskGB > 0 && `Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`} {field.value < size_gb && ( } > )} } >
e.currentTarget.blur()} onChange={(e) => { setValue('totalSize', e.target.valueAsNumber, { shouldDirty: true, shouldValidate: true, }) trigger('provisionedIOPS') trigger('throughput') }} min={includedDiskGB} />
GB
{isAllocatedStorageDirty && ( )}
)} />
{/* You can add additional content in the remaining 4 columns if needed */}
{/* Additional content or information can go here */}
{isRequestingChanges ? (

Disk configuration changes have been requested

The requested changes will be applied to your disk shortly

) : ( )}
) }