diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx index 39afca5b3..7d97e3ca5 100644 --- a/app/components/form/fields/DiskSizeField.tsx +++ b/app/components/form/fields/DiskSizeField.tsx @@ -5,7 +5,12 @@ * * Copyright Oxide Computer Company */ -import type { FieldPath, FieldPathByValue, FieldValues } from 'react-hook-form' +import type { + FieldPath, + FieldPathByValue, + FieldValues, + ValidateResult, +} from 'react-hook-form' import { MAX_DISK_SIZE_GiB } from '@oxide/api' @@ -17,12 +22,19 @@ interface DiskSizeProps< TName extends FieldPath, > extends TextFieldProps { minSize?: number + validate?(diskSizeGiB: number): ValidateResult } export function DiskSizeField< TFieldValues extends FieldValues, TName extends FieldPathByValue, ->({ required = true, name, minSize = 1, ...props }: DiskSizeProps) { +>({ + required = true, + name, + minSize = 1, + validate, + ...props +}: DiskSizeProps) { return ( { + // Run a number of default validators + if (Number.isNaN(diskSizeGiB)) { + return 'Disk size is required' + } if (diskSizeGiB < minSize) { return `Must be at least ${minSize} GiB` } if (diskSizeGiB > MAX_DISK_SIZE_GiB) { return `Can be at most ${MAX_DISK_SIZE_GiB} GiB` } + // Run any additional validators passed in from the callsite + return validate?.(diskSizeGiB) }} {...props} /> diff --git a/app/components/form/fields/NumberField.tsx b/app/components/form/fields/NumberField.tsx index 238e88b11..3e3633d28 100644 --- a/app/components/form/fields/NumberField.tsx +++ b/app/components/form/fields/NumberField.tsx @@ -77,7 +77,7 @@ export const NumberFieldInner = < name={name} control={control} rules={{ required, validate }} - render={({ field: { value, ...fieldRest }, fieldState: { error } }) => { + render={({ field, fieldState: { error } }) => { return ( <> diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 2f231d835..536998043 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -6,6 +6,7 @@ * Copyright Oxide Computer Company */ import { format } from 'date-fns' +import { useMemo } from 'react' import { useController, type Control } from 'react-hook-form' import { useNavigate, type NavigateFunction } from 'react-router-dom' @@ -17,9 +18,10 @@ import { type Disk, type DiskCreate, type DiskSource, + type Image, } from '@oxide/api' import { FieldLabel, FormDivider, Radio, RadioGroup } from '@oxide/ui' -import { GiB } from '@oxide/util' +import { bytesToGiB, GiB } from '@oxide/util' import { DescriptionField, @@ -79,6 +81,21 @@ export function CreateDiskSideModalForm({ }) const form = useForm({ defaultValues }) + const { project } = useProjectSelector() + const projectImages = useApiQuery('imageList', { query: { project } }) + const siloImages = useApiQuery('imageList', {}) + + // put project images first because if there are any, there probably aren't + // very many and they're probably relevant + const images = useMemo( + () => [...(projectImages.data?.items || []), ...(siloImages.data?.items || [])], + [projectImages.data, siloImages.data] + ) + const areImagesLoading = projectImages.isPending || siloImages.isPending + + const selectedImageId = form.watch('diskSource.imageId') + const selectedImageSize = images.find((image) => image.id === selectedImageId)?.size + const imageSizeGiB = selectedImageSize ? bytesToGiB(selectedImageSize) : undefined return ( - - + + { + if (imageSizeGiB && diskSizeGiB < imageSizeGiB) { + return `Must be as large as selected image (min. ${imageSizeGiB} GiB)` + } + }} + /> ) } -const DiskSourceField = ({ control }: { control: Control }) => { +const DiskSourceField = ({ + control, + images, + areImagesLoading, +}: { + control: Control + images: Image[] + areImagesLoading: boolean +}) => { const { field: { value, onChange }, } = useController({ control, name: 'diskSource' }) @@ -144,7 +181,17 @@ const DiskSourceField = ({ control }: { control: Control }) => { ]} /> )} - {value.type === 'image' && } + {value.type === 'image' && ( + toListboxItem(i, true))} + required + /> + )} {value.type === 'snapshot' && } @@ -152,29 +199,6 @@ const DiskSourceField = ({ control }: { control: Control }) => { ) } -const ImageSelectField = ({ control }: { control: Control }) => { - const { project } = useProjectSelector() - - const projectImages = useApiQuery('imageList', { query: { project } }) - const siloImages = useApiQuery('imageList', {}) - - // put project images first because if there are any, there probably aren't - // very many and they're probably relevant - const images = [...(projectImages.data?.items || []), ...(siloImages.data?.items || [])] - - return ( - toListboxItem(i, true))} - required - /> - ) -} - const DiskNameFromId = ({ disk }: { disk: string }) => { const { data, isPending, isError } = useApiQuery( 'diskView', diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 18c837e13..2389e133a 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -14,7 +14,6 @@ import { genName, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, - MAX_DISK_SIZE_GiB, useApiMutation, useApiQueryClient, usePrefetchedApiQuery, @@ -137,9 +136,11 @@ export function CreateInstanceForm() { const imageInput = useWatch({ control: control, name: 'image' }) const image = allImages.find((i) => i.id === imageInput) + const imageSize = image?.size ? Math.ceil(image.size / GiB) : undefined return ( values.image === i.id) + // There should always be an image present, because … + // - The form is disabled unless there are images available. + // - The form defaults to including at least one image. invariant(image, 'Expected image to be defined') const bootDiskName = values.bootDiskName || genName(values.name, image.name) @@ -287,37 +289,51 @@ export function CreateInstanceForm() { Boot disk - + 0 && siloImages.length === 0 ? 'project' : 'silo' + } + > - Project images Silo images + Project images - - {projectImages.length === 0 ? ( + {allImages.length === 0 && ( + + )} + + {siloImages.length === 0 ? (
} - title="No project images found" - body="An image needs to be uploaded to be seen here" - buttonText="Upload image" - onClick={() => navigate(pb.projectImageNew(projectSelector))} + title="No silo images found" + body="Project images need to be promoted to be seen here" />
) : ( - + )}
- - {siloImages.length === 0 ? ( + + {projectImages.length === 0 ? (
} - title="No silo images found" - body="Project images need to be promoted to be seen here" + title="No project images found" + body="An image needs to be uploaded to be seen here" + buttonText="Upload image" + onClick={() => navigate(pb.projectImageNew(projectSelector))} />
) : ( - + )}
@@ -328,15 +344,9 @@ export function CreateInstanceForm() { label="Disk size" name="bootDiskSize" control={control} - // Imitate API logic: only require that the disk is big enough to fit the image - validate={(diskSizeGiB) => { - if (!image) return true - if (diskSizeGiB < image.size / GiB) { - const minSize = Math.ceil(image.size / GiB) - return `Must be as large as selected image (min. ${minSize} GiB)` - } - if (diskSizeGiB > MAX_DISK_SIZE_GiB) { - return `Can be at most ${MAX_DISK_SIZE_GiB} GiB` + validate={(diskSizeGiB: number) => { + if (imageSize && diskSizeGiB < imageSize) { + return `Must be as large as selected image (min. ${imageSize} GiB)` } }} /> diff --git a/app/test/e2e/instance-create.e2e.ts b/app/test/e2e/instance-create.e2e.ts index 34cf965dd..e0b96c471 100644 --- a/app/test/e2e/instance-create.e2e.ts +++ b/app/test/e2e/instance-create.e2e.ts @@ -128,6 +128,43 @@ test('can create an instance with custom hardware', async ({ page }) => { ]) }) +test('automatically updates disk size when larger image selected', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'my-new-instance' + await page.fill('input[name=name]', instanceName) + + // set the disk size larger than it needs to be, to verify it doesn't get reduced + const diskSizeInput = page.getByRole('textbox', { name: 'Disk size (GiB)' }) + await diskSizeInput.fill('5') + + // pick a disk image that's smaller than 5GiB (the first project image works [4GiB]) + await page.getByRole('tab', { name: 'Project images' }).click() + await page.getByRole('button', { name: 'Image' }).click() + await page.getByRole('option', { name: images[0].name }).click() + + // test that it still says 5, as that's larger than the given image + await expect(diskSizeInput).toHaveValue('5') + + // pick a disk image that's larger than 5GiB (the third project image works [6GiB]) + await page.getByRole('button', { name: 'Image' }).click() + await page.getByRole('option', { name: images[2].name }).click() + + // test that it has been automatically increased to next-largest incremement of 10 + await expect(diskSizeInput).toHaveValue('10') + + // pick another image, just to verify that the diskSizeInput stays as it was + await page.getByRole('button', { name: 'Image' }).click() + await page.getByRole('option', { name: images[1].name }).click() + await expect(diskSizeInput).toHaveValue('10') + + const submitButton = page.getByRole('button', { name: 'Create instance' }) + await submitButton.click() + + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + await expectVisible(page, [`h1:has-text("${instanceName}")`, 'text=10 GiB']) +}) + test('with disk name already taken', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') await page.fill('input[name=name]', 'my-instance')