diff --git a/app/components/form/fields/ImageSelectField.tsx b/app/components/form/fields/ImageSelectField.tsx index cf311cbd6f..ea4aa0be4b 100644 --- a/app/components/form/fields/ImageSelectField.tsx +++ b/app/components/form/fields/ImageSelectField.tsx @@ -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 { Slash } from '~/ui/lib/Slash' import { nearest10 } from '~/util/math' import { bytesToGiB, GiB } from '~/util/units' @@ -50,10 +51,6 @@ export function BootDiskImageSelectField({ ) } -const Slash = () => ( - / -) - export function toListboxItem(i: Image, includeProjectSiloIndicator = false): ListboxItem { const { name, os, projectId, size, version } = i const formattedSize = `${bytesToGiB(size, 1)} GiB` diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index e70ccb8a3f..ae4d88d2be 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -34,6 +34,7 @@ import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' +import { Slash } from '~/ui/lib/Slash' import { toLocaleDateString } from '~/util/date' import { bytesToGiB, GiB } from '~/util/units' @@ -259,9 +260,8 @@ const SnapshotSelectField = ({ control }: { control: Control }) => {
{i.name}
Created on {toLocaleDateString(i.timeCreated)} - {' '} - /{' '} - {formattedSize.value} {formattedSize.unit} + {formattedSize.value}{' '} + {formattedSize.unit}
), diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 5bbffa4cb9..b5a8e403c3 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -20,13 +20,17 @@ import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery, + type ExternalIpCreate, + type FloatingIp, type InstanceCreate, type InstanceDiskAttachment, + type NameOrId, } from '@oxide/api' import { Images16Icon, Instances16Icon, Instances24Icon, + IpGlobal16Icon, Storage16Icon, } from '@oxide/design-system/icons/react' @@ -50,19 +54,25 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' import { TextField } from '~/components/form/fields/TextField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' +import { HL } from '~/components/HL' import { getProjectSelector, useForm, useProjectSelector } from '~/hooks' import { addToast } from '~/stores/toast' import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' 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 * as MiniTable from '~/ui/lib/MiniTable' +import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { RadioCard } from '~/ui/lib/Radio' +import { Slash } from '~/ui/lib/Slash' import { Tabs } from '~/ui/lib/Tabs' import { TextInputHint } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' +import { isTruthy } from '~/util/array' import { readBlobAsBase64 } from '~/util/file' import { docLinks, links } from '~/util/links' import { nearest10 } from '~/util/math' @@ -153,6 +163,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { }), apiQueryClient.prefetchQuery('currentUserSshKeyList', {}), apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: 1000 } }), + apiQueryClient.prefetchQuery('floatingIpList', { query: { project, limit: 1000 } }), ]) return null } @@ -573,6 +584,28 @@ export function CreateInstanceForm() { ) } +// `ip is …` guard is necessary until we upgrade to 5.5, which handles this automatically +const isFloating = ( + ip: ExternalIpCreate +): ip is { type: 'floating'; floatingIp: NameOrId } => ip.type === 'floating' + +const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( +
+
{ip.name}
+
+
{ip.ip}
+ {ip.description && ( + <> + +
+ {ip.description} +
+ + )} +
+
+) + const AdvancedAccordion = ({ control, isSubmitting, @@ -586,11 +619,65 @@ const AdvancedAccordion = ({ // tell, inside AccordionItem, when an accordion is opened so we can scroll its // contents into view const [openItems, setOpenItems] = useState([]) + const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) + const [selectedFloatingIp, setSelectedFloatingIp] = 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 + const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) + + const { project } = useProjectSelector() + const { data: floatingIpList } = usePrefetchedApiQuery('floatingIpList', { + query: { project, limit: 1000 }, + }) + + // Filter out the IPs that are already attached to an instance + const attachableFloatingIps = useMemo( + () => floatingIpList.items.filter((ip) => !ip.instanceId), + [floatingIpList] + ) + + // To find available floating IPs, we remove the ones that are already committed to this instance + const availableFloatingIps = attachableFloatingIps.filter( + (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) + ) + const attachedFloatingIpsData = attachedFloatingIps + .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) + .filter(isTruthy) + + const closeFloatingIpModal = () => { + setFloatingIpModalOpen(false) + setSelectedFloatingIp(undefined) + } + + const attachFloatingIp = () => { + if (selectedFloatingIp) { + externalIps.field.onChange([ + ...(externalIps.field.value || []), + { type: 'floating', floatingIp: selectedFloatingIp.name }, + ]) + } + closeFloatingIpModal() + } + + const detachFloatingIp = (name: string) => { + externalIps.field.onChange( + externalIps.field.value?.filter( + (ip) => !(ip.type === 'floating' && ip.floatingIp === name) + ) + ) + } + + const isFloatingIpAttached = attachedFloatingIps.some((ip) => ip.floatingIp !== '') + + const selectedFloatingIpMessage = ( + <> + This instance will be reachable at{' '} + {selectedFloatingIp ? {selectedFloatingIp.ip} : 'the selected IP'} + + ) return ( )} + +
+

+ Floating IPs{' '} + + Floating IPs exist independently of instances and can be attached to and + detached from them as needed. + +

+ {isFloatingIpAttached && ( + + + Name + IP + {/* For remove button */} + + + + {attachedFloatingIpsData.map((item, index) => ( + + {item.name} + {item.ip} + detachFloatingIp(item.name)} + label={`remove floating IP ${item.name}`} + /> + + ))} + + + )} + {floatingIpList.items.length === 0 ? ( +
+ } + title="No floating IPs found" + body="Create a floating IP to attach it to this instance" + /> +
+ ) : ( +
+ +
+ )} + + + + + +
+ ({ + value: i.name, + label: , + selectedLabel: `${i.name} (${i.ip})`, + }))} + label="Floating IP" + onChange={(name) => { + setSelectedFloatingIp( + availableFloatingIps.find((i) => i.name === name) + ) + }} + required + placeholder="Select floating IP" + selected={selectedFloatingIp?.name || ''} + /> + +
+
+ +
+
{fip.ip} {fip.description && ( <> - / +
{fip.description}
diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx index 04caef4e95..8350d92601 100644 --- a/app/ui/lib/Listbox.tsx +++ b/app/ui/lib/Listbox.tsx @@ -32,6 +32,7 @@ export interface ListboxProps { onChange: (value: Value) => void items: ListboxItem[] placeholder?: string + noItemsPlaceholder?: string className?: string disabled?: boolean hasError?: boolean @@ -48,6 +49,7 @@ export const Listbox = ({ selected, items, placeholder = 'Select an option', + noItemsPlaceholder = 'No items', className, onChange, hasError = false, @@ -107,7 +109,7 @@ export const Listbox = ({ selectedItem.selectedLabel || selectedItem.label ) : ( - {noItems ? 'No items' : placeholder} + {noItems ? noItemsPlaceholder : placeholder} )} diff --git a/app/ui/lib/Slash.tsx b/app/ui/lib/Slash.tsx new file mode 100644 index 0000000000..5d4324ff7a --- /dev/null +++ b/app/ui/lib/Slash.tsx @@ -0,0 +1,10 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +export const Slash = () => ( + / +) diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 7c4139c74a..7dcd426fb0 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -37,6 +37,18 @@ export const lookupById = (table: T[], id: string) => return item } +export const getIpFromPool = (poolName: string | undefined) => { + const pool = lookup.ipPool({ pool: poolName }) + const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) + if (!ipPoolRange) throw notFoundErr + + // right now, we're just using the first address in the range, but we'll + // want to filter the list of available IPs for the first unused address + // also: think through how calling code might want to handle various issues + // and what appropriate error codes would be: no ranges? pool is exhausted? etc. + return ipPoolRange.range.first +} + export const lookup = { project({ project: id }: PP.Project): Json { if (!id) throw notFoundErr diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 34a6629c9c..f848c99807 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -28,7 +28,14 @@ import { GiB } from '~/util/units' import { genCumulativeI64Data } from '../metrics' import { serial } from '../serial' import { defaultSilo, toIdp } from '../silo' -import { db, lookup, lookupById, notFoundErr, utilizationForSilo } from './db' +import { + db, + getIpFromPool, + lookup, + lookupById, + notFoundErr, + utilizationForSilo, +} from './db' import { currentUser, errIfExists, @@ -486,6 +493,28 @@ export const handlers = makeHandlers({ time_run_state_updated: new Date().toISOString(), } + body.external_ips?.forEach((ip) => { + if (ip.type === 'floating') { + const floatingIp = lookup.floatingIp({ + project: project.id, + floatingIp: ip.floating_ip, + }) + if (floatingIp.instance_id) { + throw 'floating IP cannot be attached to one instance while still attached to another' + } + floatingIp.instance_id = instanceId + } else if (ip.type === 'ephemeral') { + const firstAvailableAddress = getIpFromPool(ip.pool) + db.ephemeralIps.push({ + instance_id: instanceId, + external_ip: { + ip: firstAvailableAddress, + kind: 'ephemeral', + }, + }) + } + }) + setTimeout(() => { newInstance.run_state = 'starting' }, 1000) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 4ff6a7b5ed..e620818868 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { images } from '@oxide/api-mocks' +import { floatingIp, images } from '@oxide/api-mocks' import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' @@ -327,3 +327,72 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ await expect(page).toHaveURL('/projects/mock-project/instances/no-ephemeral-ip/storage') await expect(page.getByText('External IPs—')).toBeVisible() }) + +test('attaches a floating IP; disables button when no IPs available', async ({ page }) => { + const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) + const selectFloatingIpButton = page.getByRole('button', { name: 'Select floating ip' }) + const rootbeerFloatOption = page.getByRole('option', { name: 'rootbeer-float' }) + const attachButton = page.getByRole('button', { name: 'Attach', exact: true }) + + const instanceName = 'with-floating-ip' + await page.goto('/projects/mock-project/instances-new') + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await page.getByRole('button', { name: 'Networking' }).click() + + await attachFloatingIpButton.click() + await expect( + page.getByText('This instance will be reachable at the selected IP') + ).toBeVisible() + await selectFloatingIpButton.click() + await rootbeerFloatOption.click() + await expect( + page.getByText('This instance will be reachable at 123.4.56.4') + ).toBeVisible() + await attachButton.click() + await expect(page.getByText('This instance will be reachable at')).toBeHidden() + await expectRowVisible(page.getByRole('table'), { + Name: floatingIp.name, + IP: floatingIp.ip, + }) + await expect(attachFloatingIpButton).toBeDisabled() + + // removing the floating IP row should work, and should re-enable the "attach" button + await page.getByRole('button', { name: 'remove floating IP rootbeer-float' }).click() + await expect(page.getByText(floatingIp.name)).toBeHidden() + await expect(attachFloatingIpButton).toBeEnabled() + + // re-attach the floating IP + await attachFloatingIpButton.click() + await selectFloatingIpButton.click() + await rootbeerFloatOption.click() + await attachButton.click() + + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) + await expectVisible(page, [`h1:has-text("${instanceName}")`]) + await page.getByRole('tab', { name: 'Networking' }).click() + + // ensure External IPs table has rows for the Ephemeral IP and the Floating IP + await expectRowVisible(page.getByRole('table'), { + ip: '123.4.56.0', + Kind: 'ephemeral', + name: '—', + }) + await expectRowVisible(page.getByRole('table'), { + ip: floatingIp.ip, + Kind: 'floating', + name: floatingIp.name, + }) +}) + +test('attach a floating IP section has Empty version when no floating IPs exist on the project', async ({ + page, +}) => { + await page.goto('/projects/other-project/instances-new') + await page.getByRole('button', { name: 'Networking' }).click() + await expect(page.getByRole('button', { name: 'Attach floating IP' })).toBeHidden() + await expect( + page.getByText('Create a floating IP to attach it to this instance') + ).toBeVisible() +})