Files
supabase/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx
T
Joshen Lim 71a184738c Chore/more disk mgt UI updates (#29333)
* Only show new disk billing UI for AWS provider projects

* Fix DiskSpaceBar autoscaling should be fixed at 90%

* Change all MiB to MB and GiB to GB

* Prettier
2024-09-17 15:22:18 +08:00

706 lines
31 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
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/DiskSpaceBar'
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 { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
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_Shadcn_,
TooltipContent_Shadcn_,
TooltipTrigger_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { FormFooterChangeBadge } from '../DataWarehouse/FormFooterChangeBadge'
import BillingChangeBadge from './BillingChangeBadge'
import { DiskCountdownRadial } from './DiskCountdownRadial'
import {
COMPUTE_SIZE_MAX_IOPS,
COMPUTE_SIZE_MAX_THROUGHPUT,
DiskType,
IOPS_RANGE,
PLAN_DETAILS,
THROUGHPUT_RANGE,
} from './DiskManagement.constants'
import {
calculateDiskSizePrice,
calculateIOPSPrice,
calculateThroughputPrice,
} from './DiskManagement.utils'
import { DiskManagementDiskSizeReadReplicas } from './DiskManagementDiskSizeReadReplicas'
import { DiskStorageSchema, DiskStorageSchemaType } from './DiskManagementPanelSchema'
import { DiskManagementPlanUpgradeRequired } from './DiskManagementPlanUpgradeRequired'
import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog'
export function DiskManagementPanelForm() {
const org = useSelectedOrganization()
const { ref: projectRef } = useParams()
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false)
const [remainingTime, setRemainingTime] = useState(0)
const [refetchInterval, setRefetchInterval] = useState<number | false>(false)
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 [Joshen TODO] check whats happening here
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) => x.type === 'compute_instance'
)?.variant
const maxIopsBasedOnCompute =
COMPUTE_SIZE_MAX_IOPS[(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_SIZE_MAX_IOPS]
const maxThroughputBasedOnCompute =
COMPUTE_SIZE_MAX_THROUGHPUT[
(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_SIZE_MAX_THROUGHPUT
]
const { data: subscription } = useOrgSubscriptionQuery({
orgSlug: org?.slug,
})
const planId = subscription?.plan.id ?? ''
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,
provisionedIOPS: iops,
throughput: throughput_mbps,
totalSize: size_gb,
}
const form = useForm<DiskStorageSchemaType>({
resolver: zodResolver(DiskStorageSchema),
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
const { includedDiskGB: includedDiskGBMeta } =
PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {}
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 })
}
const diskSizePrice = calculateDiskSizePrice({
planId,
oldSize: form.formState.defaultValues?.totalSize || 0,
oldStorageType: form.formState.defaultValues?.storageType as DiskType,
newSize: form.getValues('totalSize'),
newStorageType: form.getValues('storageType') as DiskType,
})
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 (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`}
>
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')
}}
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={
<>
{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_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ 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_Shadcn_>
</Tooltip_Shadcn_>
</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_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ 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_Shadcn_>
</Tooltip_Shadcn_>
</div>
)}
{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>
)}
</>
}
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 items-center gap-x-2">
<span>
Throughput must be between {minThroughput} and {maxThroughput}{' '}
MB/s based on your IOPS.
</span>
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<HelpCircle
size={14}
className="transition hover:text-foreground"
/>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" className="w-64">
Min throughput is at 125MB/s, while max throughput is at
0.25MB/s * IOPS or 1000, whichever is lower
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
</div>
{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 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}.`
}
>
<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-3">
{/* 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
showNewBar={form.formState.dirtyFields.totalSize !== undefined}
totalSize={size_gb}
usedSize={mainDiskUsed}
newTotalSize={
form.getValues('totalSize') <= size_gb
? size_gb
: form.getValues('totalSize')
}
/>
<DiskManagementDiskSizeReadReplicas
isDirty={form.formState.dirtyFields.totalSize !== undefined}
totalSize={size_gb * 1.25}
usedSize={mainDiskUsed}
newTotalSize={
form.getValues('totalSize') <= size_gb
? size_gb * 1.25
: form.getValues('totalSize') * 1.25
}
/>
</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 remainingTime={remainingTime} />
)}
{isPlanUpgradeRequired && <DiskManagementPlanUpgradeRequired />}
<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
onSubmit={onSubmit}
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
isWithinCooldown={disableInput}
form={form}
loading={isUpdatingDiskConfiguration}
diskSizePrice={diskSizePrice}
iopsPrice={iopsPrice}
throughputPrice={throughputPrice}
/>
</div>
</CardContent>
</Card>
</div>
</form>
</Form_Shadcn_>
</div>
)
}