Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/components/form/fields/ImageSelectField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { Image } from '@oxide/api'

import type { InstanceCreateInput } from '~/forms/instance-create'
import type { ListboxItem } from '~/ui/lib/Listbox'
import { nearest10 } from '~/util/math'
import { bytesToGiB, GiB } from '~/util/units'

import { ListboxField } from './ListboxField'
Expand All @@ -19,19 +20,21 @@ type ImageSelectFieldProps = {
images: Image[]
control: Control<InstanceCreateInput>
disabled?: boolean
name: 'siloImageSource' | 'projectImageSource'
}

export function BootDiskImageSelectField({
images,
control,
disabled,
name,
}: ImageSelectFieldProps) {
const diskSizeField = useController({ control, name: 'bootDiskSize' }).field
return (
<ListboxField
disabled={disabled}
control={control}
name="bootDiskSource"
name={name}
label="Image"
placeholder="Select an image"
items={images.map((i) => toListboxItem(i))}
Expand All @@ -40,8 +43,7 @@ export function BootDiskImageSelectField({
const image = images.find((i) => i.id === id)! // if it's selected, it must be present
const imageSizeGiB = image.size / GiB
if (diskSizeField.value < imageSizeGiB) {
const nearest10 = Math.ceil(imageSizeGiB / 10) * 10
diskSizeField.onChange(nearest10)
diskSizeField.onChange(nearest10(imageSizeGiB))
}
}}
/>
Expand Down
100 changes: 48 additions & 52 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ import { RadioCard } from '~/ui/lib/Radio'
import { Tabs } from '~/ui/lib/Tabs'
import { TextInputHint } from '~/ui/lib/TextInput'
import { readBlobAsBase64 } from '~/util/file'
import { invariant } from '~/util/invariant'
import { links } from '~/util/links'
import { nearest10 } from '~/util/math'
import { pb } from '~/util/path-builder'
import { GiB } from '~/util/units'

Expand All @@ -71,7 +71,9 @@ export type InstanceCreateInput = Assign<
bootDiskName: string
bootDiskSize: number
bootDiskSourceType: BootDiskSourceType
bootDiskSource: string
siloImageSource: string
projectImageSource: string
diskSource: string
userData: File | null
// ssh keys are always specified. we do not need the undefined case
sshPublicKeys: NonNullable<InstanceCreate['sshPublicKeys']>
Expand All @@ -93,8 +95,10 @@ const baseDefaultValues: InstanceCreateInput = {
bootDiskName: '',
bootDiskSize: 10,

bootDiskSource: '',
bootDiskSourceType: 'siloImage',
siloImageSource: '',
projectImageSource: '',
diskSource: '',

disks: [],
networkInterfaces: { type: 'default' },
Expand Down Expand Up @@ -168,17 +172,28 @@ export function CreateInstanceForm() {
const defaultValues: InstanceCreateInput = {
...baseDefaultValues,
bootDiskSourceType: defaultSource,
bootDiskSource: defaultImage?.id || '',
siloImageSource: siloImages?.[0]?.id || '',
projectImageSource: projectImages?.[0]?.id || '',
diskSource: disks?.[0]?.value || '',
sshPublicKeys: allKeys,
// Use 2x the image size as the default boot disk size
bootDiskSize: Math.ceil(defaultImage?.size / GiB) * 2 || 10,
bootDiskSize: nearest10(defaultImage?.size / GiB),
}

const form = useForm({ defaultValues })
const { control, setValue } = form

const imageInput = useWatch({ control: control, name: 'bootDiskSource' })
const image = allImages.find((i) => i.id === imageInput)
const bootDiskSourceType = useWatch({ control: control, name: 'bootDiskSourceType' })
const siloImageSource = useWatch({ control: control, name: 'siloImageSource' })
const projectImageSource = useWatch({ control: control, name: 'projectImageSource' })
const diskSource = useWatch({ control: control, name: 'diskSource' })
const bootDiskSource =
bootDiskSourceType === 'siloImage'
? siloImageSource
: bootDiskSourceType === 'projectImage'
? projectImageSource
: diskSource
const bootDiskSize = useWatch({ control: control, name: 'bootDiskSize' })
const image = allImages.find((i) => i.id === bootDiskSource)
const imageSize = image?.size ? Math.ceil(image.size / GiB) : undefined

useEffect(() => {
Expand All @@ -196,6 +211,7 @@ export function CreateInstanceForm() {
label="Disk size"
name="bootDiskSize"
control={control}
min={imageSize || 1}
validate={(diskSizeGiB: number) => {
if (imageSize && diskSizeGiB < imageSize) {
return `Must be as large as selected image (min. ${imageSize} GiB)`
Expand All @@ -215,6 +231,23 @@ export function CreateInstanceForm() {
</>
)

const createBootDisk = (values: InstanceCreateInput) => {
if (values.bootDiskSourceType === 'disk') {
return { type: 'attach' as const, name: values.diskSource }
}
const source =
values.bootDiskSourceType === 'siloImage'
? values.siloImageSource
: values.projectImageSource
return {
type: 'create' as const,
name: values.bootDiskName || genName(values.name, source),
description: `Created as a boot disk for ${values.name}`,
size: values.bootDiskSize * GiB,
diskSource: { type: 'image' as const, imageId: source },
}
}

return (
<FullPageForm
submitDisabled={allImages.length ? undefined : 'Image required'}
Expand All @@ -231,33 +264,7 @@ export function CreateInstanceForm() {
? { memory: values.memory, ncpus: values.ncpus }
: { memory: preset.memory, ncpus: preset.ncpus }

const isDisk = values.bootDiskSourceType === 'disk'
const image = !isDisk && allImages.find((i) => values.bootDiskSource === i.id)

// There should always be an image or disk present, because …
// - The form is disabled unless there are images or disks available.
// - The form defaults to including at least one image.
invariant(
(image && values.bootDiskSize) || (isDisk && values.bootDiskSource),
'Expected boot disk to be defined'
)

const bootDisk = image
? {
type: 'create' as const,
// TODO: Determine the pattern of the default boot disk name
name: values.bootDiskName || genName(values.name, image.name),
description: `Created as a boot disk for ${values.name}`,

// Minimum size as greater than the image is validated
// directly on the boot disk size input
size: values.bootDiskSize * GiB,
diskSource: {
type: 'image' as const,
imageId: values.bootDiskSource,
},
}
: { type: 'attach' as const, name: values.bootDiskSource }
const bootDisk = createBootDisk(values)

const userData = values.userData
? await readBlobAsBase64(values.userData)
Expand Down Expand Up @@ -293,16 +300,12 @@ export function CreateInstanceForm() {
>
Start Instance
</CheckboxField>

<FormDivider />

<Form.Heading id="hardware">Hardware</Form.Heading>

<TextInputHint id="hw-gp-help-text" className="mb-12 max-w-xl text-sans-md">
Pick a pre-configured machine type that offers balanced vCPU and memory for most
workloads or create a custom machine.
</TextInputHint>

<Tabs.Root
id="choose-cpu-ram"
className="full-width"
Expand Down Expand Up @@ -398,11 +401,9 @@ export function CreateInstanceForm() {
defaultValue={defaultSource}
onValueChange={(val) => {
setValue('bootDiskSourceType', val as BootDiskSourceType)
// if the user switches to the disk tab, clear the selected image;
// if they switch to the same tab that has the defaultImage, select it
setValue('bootDiskSource', val === defaultSource ? defaultImage?.id : '')
// clear any form errors
form.clearErrors('bootDiskSource')
if (imageSize && imageSize > bootDiskSize) {
setValue('bootDiskSize', nearest10(imageSize))
}
}}
>
<Tabs.List aria-describedby="boot-disk">
Expand Down Expand Up @@ -447,6 +448,7 @@ export function CreateInstanceForm() {
images={siloImages}
control={control}
disabled={isSubmitting}
name="siloImageSource"
/>
{bootDiskSizeAndName}
</>
Expand All @@ -472,6 +474,7 @@ export function CreateInstanceForm() {
images={projectImages}
control={control}
disabled={isSubmitting}
name="projectImageSource"
/>
{bootDiskSizeAndName}
</>
Expand All @@ -490,7 +493,7 @@ export function CreateInstanceForm() {
) : (
<ListboxField
label="Disk"
name="bootDiskSource"
name="diskSource"
description="Existing disks that are not attached to an instance"
items={disks}
required
Expand All @@ -499,22 +502,15 @@ export function CreateInstanceForm() {
)}
</Tabs.Content>
</Tabs.Root>

<FormDivider />
<Form.Heading id="additional-disks">Additional disks</Form.Heading>

<DisksTableField control={control} disabled={isSubmitting} />

<FormDivider />
<Form.Heading id="authentication">Authentication</Form.Heading>

<SshKeysField control={control} isSubmitting={isSubmitting} />

<FormDivider />
<Form.Heading id="advanced">Advanced</Form.Heading>

<AdvancedAccordion control={control} isSubmitting={isSubmitting} />

<Form.Actions>
<Form.Submit loading={createInstance.isPending}>Create instance</Form.Submit>
<Form.Cancel onClick={() => navigate(pb.instances(projectSelector))} />
Expand Down
30 changes: 29 additions & 1 deletion app/util/math.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest'

import { displayBigNum, percentage, round, splitDecimal, toEngNotation } from './math'
import {
displayBigNum,
nearest10,
percentage,
round,
splitDecimal,
toEngNotation,
} from './math'
import { GiB } from './units'

function roundTest() {
Expand Down Expand Up @@ -208,3 +215,24 @@ it.each([
])('toEngNotation commas %s', (locale) => {
expect(toEngNotation(n, locale)).toEqual('23,1e27')
})

it.each([
[0, 0],
[1, 10],
[1.5, 10],
[9, 10],
[10, 10],
[10.0001, 20],
[11, 20],
[19, 20],
[20, 20],
[21, 30],
[99, 100],
[100, 100],
[101, 110],
[109, 110],
[110, 110],
[111, 120],
])('nearest10 %d → %d', (input, output) => {
expect(nearest10(input)).toEqual(output)
})
7 changes: 7 additions & 0 deletions app/util/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,10 @@ export function displayBigNum(num: bigint | number): [string, boolean] {

return [result, abbreviated]
}

/**
* Gets the closest multiple of 10 larger than the passed-in number
*/
export function nearest10(num: number): number {
return Math.ceil(num / 10) * 10
}
Loading