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
2 changes: 1 addition & 1 deletion app/components/AccordionItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemP
<Accordion.Content
ref={contentRef}
forceMount
className={cn('ox-accordion-content overflow-hidden py-8', { hidden: !isOpen })}
className={cn('ox-accordion-content py-8', { hidden: !isOpen })}
>
{children}
</Accordion.Content>
Expand Down
102 changes: 93 additions & 9 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import * as Accordion from '@radix-ui/react-accordion'
import { useEffect, useMemo, useState } from 'react'
import { useWatch, type Control } from 'react-hook-form'
import { useController, useWatch, type Control } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
import type { SetRequired } from 'type-fest'

Expand Down Expand Up @@ -50,12 +50,16 @@ import { Form } from '~/components/form/Form'
import { FullPageForm } from '~/components/form/FullPageForm'
import { getProjectSelector, useForm, useProjectSelector } from '~/hooks'
import { addToast } from '~/stores/toast'
import { Badge } from '~/ui/lib/Badge'
import { Checkbox } from '~/ui/lib/Checkbox'
import { FormDivider } from '~/ui/lib/Divider'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { Listbox } from '~/ui/lib/Listbox'
import { Message } from '~/ui/lib/Message'
import { RadioCard } from '~/ui/lib/Radio'
import { Tabs } from '~/ui/lib/Tabs'
import { TextInputHint } from '~/ui/lib/TextInput'
import { TipIcon } from '~/ui/lib/TipIcon'
import { readBlobAsBase64 } from '~/util/file'
import { links } from '~/util/links'
import { nearest10 } from '~/util/math'
Expand Down Expand Up @@ -130,6 +134,7 @@ const baseDefaultValues: InstanceCreateInput = {
start: true,

userData: null,
externalIps: [{ type: 'ephemeral' }],
}

const DISK_FETCH_LIMIT = 1000
Expand All @@ -144,6 +149,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
query: { project, limit: DISK_FETCH_LIMIT },
}),
apiQueryClient.prefetchQuery('currentUserSshKeyList', {}),
apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: 1000 } }),
])
return null
}
Expand Down Expand Up @@ -187,6 +193,15 @@ export function CreateInstanceForm() {
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])

// projectIpPoolList fetches the pools linked to the current silo
const { data: siloPools } = usePrefetchedApiQuery('projectIpPoolList', {
query: { limit: 1000 },
})
const defaultPool = useMemo(
() => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined),
[siloPools]
)

const defaultSource =
siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk'

Expand All @@ -198,6 +213,7 @@ export function CreateInstanceForm() {
diskSource: disks?.[0]?.value || '',
sshPublicKeys: allKeys,
bootDiskSize: nearest10(defaultImage?.size / GiB),
externalIps: [{ type: 'ephemeral', pool: defaultPool }],
}

const form = useForm({ defaultValues })
Expand Down Expand Up @@ -283,7 +299,7 @@ export function CreateInstanceForm() {
memory: instance.memory * GiB,
ncpus: instance.ncpus,
disks: [bootDisk, ...values.disks],
externalIps: [{ type: 'ephemeral' }],
externalIps: values.externalIps,
start: values.start,
networkInterfaces: values.networkInterfaces,
sshPublicKeys: values.sshPublicKeys,
Expand Down Expand Up @@ -514,7 +530,11 @@ export function CreateInstanceForm() {
<SshKeysField control={control} isSubmitting={isSubmitting} />
<FormDivider />
<Form.Heading id="advanced">Advanced</Form.Heading>
<AdvancedAccordion control={control} isSubmitting={isSubmitting} />
<AdvancedAccordion
control={control}
isSubmitting={isSubmitting}
siloPools={siloPools.items}
/>
<Form.Actions>
<Form.Submit loading={createInstance.isPending}>Create instance</Form.Submit>
<Form.Cancel onClick={() => navigate(pb.instances({ project }))} />
Expand All @@ -526,14 +546,21 @@ export function CreateInstanceForm() {
const AdvancedAccordion = ({
control,
isSubmitting,
siloPools,
}: {
control: Control<InstanceCreateInput>
isSubmitting: boolean
siloPools: Array<{ name: string; isDefault: boolean }>
}) => {
// we track this state manually for the sole reason that we need to be able to
// tell, inside AccordionItem, when an accordion is opened so we can scroll its
// contents into view
const [openItems, setOpenItems] = useState<string[]>([])
const externalIps = useController({ control, name: 'externalIps' })
const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral')
const assignEphemeralIp = !!ephemeralIp
const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined
const defaultPool = siloPools.find((pool) => pool.isDefault)?.name

return (
<Accordion.Root
Expand All @@ -549,12 +576,69 @@ const AdvancedAccordion = ({
>
<NetworkInterfaceField control={control} disabled={isSubmitting} />

<TextField
name="hostname"
tooltipText="Will be generated if not provided"
control={control}
disabled={isSubmitting}
/>
<div className="py-2">
<TextField
name="hostname"
tooltipText="Will be generated if not provided"
control={control}
disabled={isSubmitting}
/>
</div>

<div className="flex flex-1 flex-col gap-4">
<h2 className="text-sans-md">
Ephemeral IP{' '}
<TipIcon>
Ephemeral IPs are allocated when the instance is created and deallocated when
it is deleted
</TipIcon>
</h2>
<div className="flex items-start gap-2.5">
<Checkbox
id="assignEphemeralIp"
checked={assignEphemeralIp}
onChange={() => {
const newExternalIps = assignEphemeralIp
? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral')
: [
...(externalIps.field.value || []),
{ type: 'ephemeral', pool: selectedPool || defaultPool },
]
externalIps.field.onChange(newExternalIps)
}}
/>
<label htmlFor="assignEphemeralIp" className="text-sans-md text-secondary">
Allocate and attach an ephemeral IP address
</label>
</div>
{assignEphemeralIp && (
<Listbox
name="pools"
label="IP pool for ephemeral IP"
placeholder={defaultPool ? `${defaultPool} (default)` : 'Select pool'}
selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`}
items={
siloPools.map((pool) => ({
label: (
<div className="flex items-center gap-2">
{pool.name}
{pool.isDefault && <Badge>default</Badge>}
</div>
),
value: pool.name,
})) || []
}
disabled={!assignEphemeralIp || isSubmitting}
required
onChange={(value) => {
const newExternalIps = externalIps.field.value?.map((ip) =>
ip.type === 'ephemeral' ? { ...ip, pool: value } : ip
)
externalIps.field.onChange(newExternalIps)
}}
/>
)}
</div>
</AccordionItem>
<AccordionItem
value="configuration"
Expand Down
12 changes: 3 additions & 9 deletions app/ui/lib/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,7 @@ import { TextInputHint } from './TextInput'

export type ListboxItem<Value extends string = string> = {
value: Value
} & (
| { label: string; labelString?: never }
// labelString is required when `label` is a `ReactElement` because we
// need need a one-line string to display in the button when the item is
// selected.
| { label: ReactNode; labelString: string }
)
} & { label?: string | ReactNode; labelString?: string }

export interface ListboxProps<Value extends string = string> {
// null is allowed as a default empty value, but onChange will never be called with null
Expand All @@ -44,9 +38,9 @@ export interface ListboxProps<Value extends string = string> {
disabled?: boolean
hasError?: boolean
name?: string
label?: string
label?: React.ReactNode
tooltipText?: string
description?: string | React.ReactNode
description?: React.ReactNode
required?: boolean
isLoading?: boolean
}
Expand Down
2 changes: 2 additions & 0 deletions app/util/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const links: Record<string, string> = {
cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html',
cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html',
disksDocs: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots',
externalAddresses:
'https://docs.oxide.computer/guides/operator/ip-pool-management#_external_address_categorization',
firewallRulesDocs:
'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules',
floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips',
Expand Down
33 changes: 33 additions & 0 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,27 @@ test('can create an instance', async ({ page }) => {
await page.getByRole('button', { name: 'Networking' }).click()
await page.getByRole('button', { name: 'Configuration' }).click()

const assignEphemeralIpCheckbox = page.getByRole('checkbox', {
name: 'Allocate and attach an ephemeral IP address',
})
const assignEphemeralIpButton = page.getByRole('button', {
name: 'IP pool for ephemeral IP',
})

// verify that the ip pool selector is visible and default is selected
await expect(assignEphemeralIpCheckbox).toBeChecked()
await assignEphemeralIpButton.click()
await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled()

// unchecking the box should disable the selector
await assignEphemeralIpCheckbox.uncheck()
await expect(assignEphemeralIpButton).toBeHidden()

// re-checking the box should re-enable the selector, and other options should be selectable
await assignEphemeralIpCheckbox.check()
await assignEphemeralIpButton.click()
await page.getByRole('option', { name: 'ip-pool-2' }).click()

// should be visible in accordion
await expectVisible(page, [
'role=radiogroup[name="Network interface"]',
Expand Down Expand Up @@ -293,3 +314,15 @@ test('maintains selected values even when changing tabs', async ({ page }) => {
// so this checks to make sure that the arch-based image — with ID `bd6aa051…` — was used
await expectVisible(page, [`text=${instanceName}-bd6aa051`])
})

test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ page }) => {
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip')
await page.getByRole('button', { name: 'Networking' }).click()
await page
.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' })
.uncheck()
await page.getByRole('button', { name: 'Create instance' }).click()
await expect(page).toHaveURL('/projects/mock-project/instances/no-ephemeral-ip/storage')
await expect(page.getByText('External IPs—')).toBeVisible()
})