diff --git a/app/components/AccordionItem.tsx b/app/components/AccordionItem.tsx index 8bf2365120..227d8583ae 100644 --- a/app/components/AccordionItem.tsx +++ b/app/components/AccordionItem.tsx @@ -37,7 +37,7 @@ export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemP {children} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 6d53acc033..9e74ab96da 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -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' @@ -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' @@ -130,6 +134,7 @@ const baseDefaultValues: InstanceCreateInput = { start: true, userData: null, + externalIps: [{ type: 'ephemeral' }], } const DISK_FETCH_LIMIT = 1000 @@ -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 } @@ -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' @@ -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 }) @@ -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, @@ -514,7 +530,11 @@ export function CreateInstanceForm() { Advanced - + Create instance navigate(pb.instances({ project }))} /> @@ -526,14 +546,21 @@ export function CreateInstanceForm() { const AdvancedAccordion = ({ control, isSubmitting, + siloPools, }: { control: Control 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([]) + 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 ( - +
+ +
+ +
+

+ Ephemeral IP{' '} + + Ephemeral IPs are allocated when the instance is created and deallocated when + it is deleted + +

+
+ { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [ + ...(externalIps.field.value || []), + { type: 'ephemeral', pool: selectedPool || defaultPool }, + ] + externalIps.field.onChange(newExternalIps) + }} + /> + +
+ {assignEphemeralIp && ( + pool.name === selectedPool)?.name}`} + items={ + siloPools.map((pool) => ({ + label: ( +
+ {pool.name} + {pool.isDefault && default} +
+ ), + 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) + }} + /> + )} +
= { 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 { // null is allowed as a default empty value, but onChange will never be called with null @@ -44,9 +38,9 @@ export interface ListboxProps { disabled?: boolean hasError?: boolean name?: string - label?: string + label?: React.ReactNode tooltipText?: string - description?: string | React.ReactNode + description?: React.ReactNode required?: boolean isLoading?: boolean } diff --git a/app/util/links.ts b/app/util/links.ts index 9fa5cabbae..b9b5a4298a 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -10,6 +10,8 @@ export const links: Record = { 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', diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 13b82d208b..d8b8ed006d 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -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"]', @@ -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() +})