Skip to content

Commit b400ae7

Browse files
authored
Add external IP selection to instance creation form (#2225)
1 parent 7bb3bbf commit b400ae7

File tree

5 files changed

+132
-19
lines changed

5 files changed

+132
-19
lines changed

app/components/AccordionItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemP
3737
<Accordion.Content
3838
ref={contentRef}
3939
forceMount
40-
className={cn('ox-accordion-content overflow-hidden py-8', { hidden: !isOpen })}
40+
className={cn('ox-accordion-content py-8', { hidden: !isOpen })}
4141
>
4242
{children}
4343
</Accordion.Content>

app/forms/instance-create.tsx

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import * as Accordion from '@radix-ui/react-accordion'
99
import { useEffect, useMemo, useState } from 'react'
10-
import { useWatch, type Control } from 'react-hook-form'
10+
import { useController, useWatch, type Control } from 'react-hook-form'
1111
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
1212
import type { SetRequired } from 'type-fest'
1313

@@ -50,12 +50,16 @@ import { Form } from '~/components/form/Form'
5050
import { FullPageForm } from '~/components/form/FullPageForm'
5151
import { getProjectSelector, useForm, useProjectSelector } from '~/hooks'
5252
import { addToast } from '~/stores/toast'
53+
import { Badge } from '~/ui/lib/Badge'
54+
import { Checkbox } from '~/ui/lib/Checkbox'
5355
import { FormDivider } from '~/ui/lib/Divider'
5456
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
57+
import { Listbox } from '~/ui/lib/Listbox'
5558
import { Message } from '~/ui/lib/Message'
5659
import { RadioCard } from '~/ui/lib/Radio'
5760
import { Tabs } from '~/ui/lib/Tabs'
5861
import { TextInputHint } from '~/ui/lib/TextInput'
62+
import { TipIcon } from '~/ui/lib/TipIcon'
5963
import { readBlobAsBase64 } from '~/util/file'
6064
import { links } from '~/util/links'
6165
import { nearest10 } from '~/util/math'
@@ -130,6 +134,7 @@ const baseDefaultValues: InstanceCreateInput = {
130134
start: true,
131135

132136
userData: null,
137+
externalIps: [{ type: 'ephemeral' }],
133138
}
134139

135140
const DISK_FETCH_LIMIT = 1000
@@ -144,6 +149,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
144149
query: { project, limit: DISK_FETCH_LIMIT },
145150
}),
146151
apiQueryClient.prefetchQuery('currentUserSshKeyList', {}),
152+
apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: 1000 } }),
147153
])
148154
return null
149155
}
@@ -187,6 +193,15 @@ export function CreateInstanceForm() {
187193
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
188194
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])
189195

196+
// projectIpPoolList fetches the pools linked to the current silo
197+
const { data: siloPools } = usePrefetchedApiQuery('projectIpPoolList', {
198+
query: { limit: 1000 },
199+
})
200+
const defaultPool = useMemo(
201+
() => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined),
202+
[siloPools]
203+
)
204+
190205
const defaultSource =
191206
siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk'
192207

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

203219
const form = useForm({ defaultValues })
@@ -283,7 +299,7 @@ export function CreateInstanceForm() {
283299
memory: instance.memory * GiB,
284300
ncpus: instance.ncpus,
285301
disks: [bootDisk, ...values.disks],
286-
externalIps: [{ type: 'ephemeral' }],
302+
externalIps: values.externalIps,
287303
start: values.start,
288304
networkInterfaces: values.networkInterfaces,
289305
sshPublicKeys: values.sshPublicKeys,
@@ -514,7 +530,11 @@ export function CreateInstanceForm() {
514530
<SshKeysField control={control} isSubmitting={isSubmitting} />
515531
<FormDivider />
516532
<Form.Heading id="advanced">Advanced</Form.Heading>
517-
<AdvancedAccordion control={control} isSubmitting={isSubmitting} />
533+
<AdvancedAccordion
534+
control={control}
535+
isSubmitting={isSubmitting}
536+
siloPools={siloPools.items}
537+
/>
518538
<Form.Actions>
519539
<Form.Submit loading={createInstance.isPending}>Create instance</Form.Submit>
520540
<Form.Cancel onClick={() => navigate(pb.instances({ project }))} />
@@ -526,14 +546,21 @@ export function CreateInstanceForm() {
526546
const AdvancedAccordion = ({
527547
control,
528548
isSubmitting,
549+
siloPools,
529550
}: {
530551
control: Control<InstanceCreateInput>
531552
isSubmitting: boolean
553+
siloPools: Array<{ name: string; isDefault: boolean }>
532554
}) => {
533555
// we track this state manually for the sole reason that we need to be able to
534556
// tell, inside AccordionItem, when an accordion is opened so we can scroll its
535557
// contents into view
536558
const [openItems, setOpenItems] = useState<string[]>([])
559+
const externalIps = useController({ control, name: 'externalIps' })
560+
const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral')
561+
const assignEphemeralIp = !!ephemeralIp
562+
const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined
563+
const defaultPool = siloPools.find((pool) => pool.isDefault)?.name
537564

538565
return (
539566
<Accordion.Root
@@ -549,12 +576,69 @@ const AdvancedAccordion = ({
549576
>
550577
<NetworkInterfaceField control={control} disabled={isSubmitting} />
551578

552-
<TextField
553-
name="hostname"
554-
tooltipText="Will be generated if not provided"
555-
control={control}
556-
disabled={isSubmitting}
557-
/>
579+
<div className="py-2">
580+
<TextField
581+
name="hostname"
582+
tooltipText="Will be generated if not provided"
583+
control={control}
584+
disabled={isSubmitting}
585+
/>
586+
</div>
587+
588+
<div className="flex flex-1 flex-col gap-4">
589+
<h2 className="text-sans-md">
590+
Ephemeral IP{' '}
591+
<TipIcon>
592+
Ephemeral IPs are allocated when the instance is created and deallocated when
593+
it is deleted
594+
</TipIcon>
595+
</h2>
596+
<div className="flex items-start gap-2.5">
597+
<Checkbox
598+
id="assignEphemeralIp"
599+
checked={assignEphemeralIp}
600+
onChange={() => {
601+
const newExternalIps = assignEphemeralIp
602+
? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral')
603+
: [
604+
...(externalIps.field.value || []),
605+
{ type: 'ephemeral', pool: selectedPool || defaultPool },
606+
]
607+
externalIps.field.onChange(newExternalIps)
608+
}}
609+
/>
610+
<label htmlFor="assignEphemeralIp" className="text-sans-md text-secondary">
611+
Allocate and attach an ephemeral IP address
612+
</label>
613+
</div>
614+
{assignEphemeralIp && (
615+
<Listbox
616+
name="pools"
617+
label="IP pool for ephemeral IP"
618+
placeholder={defaultPool ? `${defaultPool} (default)` : 'Select pool'}
619+
selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`}
620+
items={
621+
siloPools.map((pool) => ({
622+
label: (
623+
<div className="flex items-center gap-2">
624+
{pool.name}
625+
{pool.isDefault && <Badge>default</Badge>}
626+
</div>
627+
),
628+
value: pool.name,
629+
})) || []
630+
}
631+
disabled={!assignEphemeralIp || isSubmitting}
632+
required
633+
onChange={(value) => {
634+
const newExternalIps = externalIps.field.value?.map((ip) =>
635+
ip.type === 'ephemeral' ? { ...ip, pool: value } : ip
636+
)
637+
externalIps.field.onChange(newExternalIps)
638+
}}
639+
/>
640+
)}
641+
</div>
558642
</AccordionItem>
559643
<AccordionItem
560644
value="configuration"

app/ui/lib/Listbox.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,7 @@ import { TextInputHint } from './TextInput'
2626

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

3731
export interface ListboxProps<Value extends string = string> {
3832
// null is allowed as a default empty value, but onChange will never be called with null
@@ -44,9 +38,9 @@ export interface ListboxProps<Value extends string = string> {
4438
disabled?: boolean
4539
hasError?: boolean
4640
name?: string
47-
label?: string
41+
label?: React.ReactNode
4842
tooltipText?: string
49-
description?: string | React.ReactNode
43+
description?: React.ReactNode
5044
required?: boolean
5145
isLoading?: boolean
5246
}

app/util/links.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export const links: Record<string, string> = {
1010
cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html',
1111
cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html',
1212
disksDocs: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots',
13+
externalAddresses:
14+
'https://docs.oxide.computer/guides/operator/ip-pool-management#_external_address_categorization',
1315
firewallRulesDocs:
1416
'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules',
1517
floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips',

test/e2e/instance-create.e2e.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ test('can create an instance', async ({ page }) => {
5151
await page.getByRole('button', { name: 'Networking' }).click()
5252
await page.getByRole('button', { name: 'Configuration' }).click()
5353

54+
const assignEphemeralIpCheckbox = page.getByRole('checkbox', {
55+
name: 'Allocate and attach an ephemeral IP address',
56+
})
57+
const assignEphemeralIpButton = page.getByRole('button', {
58+
name: 'IP pool for ephemeral IP',
59+
})
60+
61+
// verify that the ip pool selector is visible and default is selected
62+
await expect(assignEphemeralIpCheckbox).toBeChecked()
63+
await assignEphemeralIpButton.click()
64+
await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled()
65+
66+
// unchecking the box should disable the selector
67+
await assignEphemeralIpCheckbox.uncheck()
68+
await expect(assignEphemeralIpButton).toBeHidden()
69+
70+
// re-checking the box should re-enable the selector, and other options should be selectable
71+
await assignEphemeralIpCheckbox.check()
72+
await assignEphemeralIpButton.click()
73+
await page.getByRole('option', { name: 'ip-pool-2' }).click()
74+
5475
// should be visible in accordion
5576
await expectVisible(page, [
5677
'role=radiogroup[name="Network interface"]',
@@ -293,3 +314,15 @@ test('maintains selected values even when changing tabs', async ({ page }) => {
293314
// so this checks to make sure that the arch-based image — with ID `bd6aa051…` — was used
294315
await expectVisible(page, [`text=${instanceName}-bd6aa051`])
295316
})
317+
318+
test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ page }) => {
319+
await page.goto('/projects/mock-project/instances-new')
320+
await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip')
321+
await page.getByRole('button', { name: 'Networking' }).click()
322+
await page
323+
.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' })
324+
.uncheck()
325+
await page.getByRole('button', { name: 'Create instance' }).click()
326+
await expect(page).toHaveURL('/projects/mock-project/instances/no-ephemeral-ip/storage')
327+
await expect(page.getByText('External IPs—')).toBeVisible()
328+
})

0 commit comments

Comments
 (0)