DiskSizeField.tsx203 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import dayjs from 'dayjs' |
| 3 | import { RotateCcw } from 'lucide-react' |
| 4 | import { UseFormReturn } from 'react-hook-form' |
| 5 | import { |
| 6 | Button, |
| 7 | FormControl, |
| 8 | FormField, |
| 9 | FormInputGroupInput, |
| 10 | InputGroup, |
| 11 | InputGroupAddon, |
| 12 | InputGroupButton, |
| 13 | InputGroupText, |
| 14 | } from 'ui' |
| 15 | import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' |
| 16 | |
| 17 | import { DiskStorageSchemaType } from '../DiskManagement.schema' |
| 18 | import { calculateDiskSizePrice } from '../DiskManagement.utils' |
| 19 | import { BillingChangeBadge } from '../ui/BillingChangeBadge' |
| 20 | import { DiskType, PLAN_DETAILS } from '../ui/DiskManagement.constants' |
| 21 | import { DiskManagementDiskSizeReadReplicas } from '../ui/DiskManagementReadReplicas' |
| 22 | import { DiskSpaceBar } from '../ui/DiskSpaceBar' |
| 23 | import { DiskTypeRecommendationSection } from '../ui/DiskTypeRecommendationSection' |
| 24 | import FormMessage from '../ui/FormMessage' |
| 25 | import { DocsButton } from '@/components/ui/DocsButton' |
| 26 | import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query' |
| 27 | import { useDiskUtilizationQuery } from '@/data/config/disk-utilization-query' |
| 28 | import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' |
| 29 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 30 | import { DOCS_URL, GB } from '@/lib/constants' |
| 31 | |
| 32 | type DiskSizeFieldProps = { |
| 33 | form: UseFormReturn<DiskStorageSchemaType> |
| 34 | disableInput: boolean |
| 35 | setAdvancedSettingsOpenState: (state: boolean) => void |
| 36 | } |
| 37 | |
| 38 | export function DiskSizeField({ |
| 39 | form, |
| 40 | disableInput, |
| 41 | setAdvancedSettingsOpenState, |
| 42 | }: DiskSizeFieldProps) { |
| 43 | const { ref: projectRef } = useParams() |
| 44 | const { control, formState, setValue, trigger, getValues, resetField, watch } = form |
| 45 | const { data: org } = useSelectedOrganizationQuery() |
| 46 | const { data: project } = useSelectedProjectQuery() |
| 47 | |
| 48 | const { error: diskAttributesError, isError: isDiskAttributesError } = useDiskAttributesQuery( |
| 49 | { projectRef }, |
| 50 | { enabled: project && project.cloud_provider !== 'FLY' } |
| 51 | ) |
| 52 | |
| 53 | const { |
| 54 | data: diskUtil, |
| 55 | error: diskUtilError, |
| 56 | isError: isDiskUtilizationError, |
| 57 | } = useDiskUtilizationQuery( |
| 58 | { |
| 59 | projectRef: projectRef, |
| 60 | }, |
| 61 | { enabled: project && project.cloud_provider !== 'FLY' } |
| 62 | ) |
| 63 | |
| 64 | const error = diskUtilError || diskAttributesError |
| 65 | const isError = isDiskUtilizationError || isDiskAttributesError |
| 66 | |
| 67 | // coming up typically takes 5 minutes, and the request is cached for 5 mins |
| 68 | // so doing less than 10 mins to account for both |
| 69 | const isProjectNew = |
| 70 | dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < 10 || |
| 71 | project?.status === 'COMING_UP' |
| 72 | |
| 73 | const watchedStorageType = watch('storageType') |
| 74 | const watchedTotalSize = watch('totalSize') |
| 75 | |
| 76 | const planId = org?.plan.id ?? 'free' |
| 77 | |
| 78 | const { includedDiskGB: includedDiskGBMeta } = |
| 79 | PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} |
| 80 | const includedDiskGB = includedDiskGBMeta[watchedStorageType] |
| 81 | |
| 82 | const { defaultValues, dirtyFields, isDirty, errors } = formState |
| 83 | const diskSizePrice = calculateDiskSizePrice({ |
| 84 | planId, |
| 85 | oldSize: defaultValues?.totalSize || 0, |
| 86 | oldStorageType: defaultValues?.storageType as DiskType, |
| 87 | newSize: getValues('totalSize'), |
| 88 | newStorageType: getValues('storageType') as DiskType, |
| 89 | }) |
| 90 | |
| 91 | const mainDiskUsed = Math.round(((diskUtil?.metrics.fs_used_bytes ?? 0) / GB) * 100) / 100 |
| 92 | |
| 93 | return ( |
| 94 | <div id="disk-size" className="grid @xl:grid-cols-12 gap-5"> |
| 95 | <div className="col-span-4"> |
| 96 | <FormField |
| 97 | name="totalSize" |
| 98 | control={control} |
| 99 | render={({ field, fieldState: { isDirty } }) => ( |
| 100 | <FormItemLayout label="Disk Size" layout="vertical" id={field.name}> |
| 101 | <FormControl className="max-w-32"> |
| 102 | <InputGroup> |
| 103 | <FormInputGroupInput |
| 104 | type="number" |
| 105 | id={field.name} |
| 106 | {...field} |
| 107 | disabled={disableInput || isError} |
| 108 | onWheel={(e) => e.currentTarget.blur()} |
| 109 | onChange={(e) => { |
| 110 | setValue('totalSize', e.target.valueAsNumber, { |
| 111 | shouldDirty: true, |
| 112 | shouldValidate: true, |
| 113 | }) |
| 114 | trigger('provisionedIOPS') |
| 115 | trigger('throughput') |
| 116 | }} |
| 117 | min={includedDiskGB} |
| 118 | /> |
| 119 | <InputGroupAddon align="inline-end"> |
| 120 | <InputGroupText>GB</InputGroupText> |
| 121 | {isDirty ? ( |
| 122 | <InputGroupButton |
| 123 | htmlType="button" |
| 124 | type="default" |
| 125 | size="tiny" |
| 126 | className="px-2 text-foreground-light" |
| 127 | onClick={() => { |
| 128 | resetField('totalSize') |
| 129 | trigger('provisionedIOPS') |
| 130 | }} |
| 131 | title="Reset" |
| 132 | > |
| 133 | <RotateCcw className="h-4 w-4" aria-hidden="true" /> |
| 134 | </InputGroupButton> |
| 135 | ) : null} |
| 136 | </InputGroupAddon> |
| 137 | </InputGroup> |
| 138 | </FormControl> |
| 139 | </FormItemLayout> |
| 140 | )} |
| 141 | /> |
| 142 | <div className="flex flex-col gap-1"> |
| 143 | <BillingChangeBadge |
| 144 | className="mt-1" |
| 145 | beforePrice={Number(diskSizePrice.oldPrice)} |
| 146 | afterPrice={Number(diskSizePrice.newPrice)} |
| 147 | show={isDirty && !errors.totalSize && diskSizePrice.oldPrice !== diskSizePrice.newPrice} |
| 148 | /> |
| 149 | <span className="text-foreground-lighter text-sm"> |
| 150 | {includedDiskGB > 0 && |
| 151 | org?.plan.id && |
| 152 | `Your plan includes up to ${includedDiskGB} GB of ${watchedStorageType} storage.`} |
| 153 | |
| 154 | <div className="mt-3"> |
| 155 | <DocsButton abbrev={false} href={`${DOCS_URL}/guides/platform/database-size`} /> |
| 156 | </div> |
| 157 | </span> |
| 158 | <DiskTypeRecommendationSection |
| 159 | form={form} |
| 160 | actions={ |
| 161 | <Button |
| 162 | type="default" |
| 163 | onClick={() => { |
| 164 | setValue('storageType', 'io2') |
| 165 | trigger('provisionedIOPS') |
| 166 | trigger('totalSize') |
| 167 | setAdvancedSettingsOpenState(true) |
| 168 | }} |
| 169 | > |
| 170 | Change to High Performance SSD |
| 171 | </Button> |
| 172 | } |
| 173 | /> |
| 174 | </div> |
| 175 | </div> |
| 176 | <div className="col-span-8"> |
| 177 | <DiskSpaceBar form={form} /> |
| 178 | |
| 179 | {isProjectNew ? ( |
| 180 | <FormMessage |
| 181 | message="Disk size data is not available for ~10 minutes after project creation" |
| 182 | type="error" |
| 183 | /> |
| 184 | ) : ( |
| 185 | error && ( |
| 186 | <FormMessage message="Failed to load disk size data" type="error"> |
| 187 | {error?.message} |
| 188 | </FormMessage> |
| 189 | ) |
| 190 | )} |
| 191 | |
| 192 | <DiskManagementDiskSizeReadReplicas |
| 193 | isDirty={dirtyFields.totalSize !== undefined} |
| 194 | totalSize={(defaultValues?.totalSize || 0) * 1.25} |
| 195 | usedSize={mainDiskUsed} |
| 196 | newTotalSize={watchedTotalSize * 1.25} |
| 197 | oldStorageType={defaultValues?.storageType as DiskType} |
| 198 | newStorageType={getValues('storageType') as DiskType} |
| 199 | /> |
| 200 | </div> |
| 201 | </div> |
| 202 | ) |
| 203 | } |