diff --git a/app/components/form/fields/FileField.tsx b/app/components/form/fields/FileField.tsx index c1cb0442c..a3e396a14 100644 --- a/app/components/form/fields/FileField.tsx +++ b/app/components/form/fields/FileField.tsx @@ -46,7 +46,7 @@ export function FileField< fieldState: { error }, } = useController({ name, control, rules: { required } }) return ( -
+
{label} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 2ebe3d923..5c0941b68 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -5,7 +5,6 @@ * * Copyright Oxide Computer Company */ -import * as Accordion from '@radix-ui/react-accordion' import { useEffect, useMemo, useRef, useState } from 'react' import { useController, useForm, useWatch, type Control } from 'react-hook-form' import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' @@ -44,7 +43,6 @@ import { Storage16Icon, } from '@oxide/design-system/icons/react' -import { AccordionItem } from '~/components/AccordionItem' import { DocsPopover } from '~/components/DocsPopover' import { CheckboxField } from '~/components/form/fields/CheckboxField' import { ComboboxField } from '~/components/form/fields/ComboboxField' @@ -720,13 +718,23 @@ export default function CreateInstanceForm() { Authentication - Advanced - Networking + + + Advanced + } + name="userData" + label="User Data" + control={control} + disabled={isSubmitting} + /> Create instance navigate(pb.instances({ project }))} /> @@ -753,7 +761,7 @@ const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => (
) -const AdvancedAccordion = ({ +const NetworkingSection = ({ control, isSubmitting, unicastPools, @@ -765,10 +773,6 @@ const AdvancedAccordion = ({ hasVpcs: boolean }) => { const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) - // 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 [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) const [selectedFloatingIp, setSelectedFloatingIp] = useState() const assignEphemeralIpField = useController({ control, name: 'assignEphemeralIp' }) @@ -919,184 +923,153 @@ const AdvancedAccordion = ({ ) return ( - - - {!hasVpcs && ( - - A VPC is required to add network interfaces.{' '} - Create a VPC to enable networking. - - } - /> - )} - + {!hasVpcs && ( + + A VPC is required to add network interfaces.{' '} + Create a VPC to enable networking. + + } + /> + )} + + +
+

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

+ + } + > + {/* TODO: Wrapping the checkbox in a makes it so the tooltip + * shows up when you hover anywhere on the label or checkbox, not + * just the checkbox itself. The downside is the placement of the tooltip + * is a little weird (I'd like it better if it was anchored to the checkbox), + * but I think having it show up on label hover is worth it. + */} + + { + assignEphemeralIpField.field.onChange(!assignEphemeralIp) + }} + > + Allocate and attach an ephemeral IP address + + + + +
-
-

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

- - } - > - {/* TODO: Wrapping the checkbox in a makes it so the tooltip - * shows up when you hover anywhere on the label or checkbox, not - * just the checkbox itself. The downside is the placement of the tooltip - * is a little weird (I'd like it better if it was anchored to the checkbox), - * but I think having it show up on label hover is worth it. - */} - - { - assignEphemeralIpField.field.onChange(!assignEphemeralIp) - }} - > - Allocate and attach an ephemeral IP address - - - - -
- -
-

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

- {floatingIpList.items.length === 0 ? ( -
- } - title="No floating IPs found" - body="Create a floating IP to attach it to this instance" - /> -
- ) : ( -
- item.name }, - { header: 'IP', cell: (item) => item.ip }, - ]} - rowKey={(item) => item.name} - onRemoveItem={(item) => detachFloatingIp(item.name)} - removeLabel={(item) => `remove floating IP ${item.name}`} - /> - -
- )} - +

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

+ {floatingIpList.items.length === 0 ? ( +
+ } + title="No floating IPs found" + body="Create a floating IP to attach it to this instance" + /> +
+ ) : ( +
+ item.name }, + { header: 'IP', cell: (item) => item.ip }, + ]} + rowKey={(item) => item.name} + onRemoveItem={(item) => detachFloatingIp(item.name)} + removeLabel={(item) => `remove floating IP ${item.name}`} + /> + +
+ )} + + + + +
+ ({ + value: i.name, + label: , + selectedLabel: `${i.name} (${i.ip})`, + }))} + label="Floating IP" + onChange={(name) => { + setSelectedFloatingIp(availableFloatingIps.find((i) => i.name === name)) + }} + required + placeholder="Select a floating IP" + selected={selectedFloatingIp?.name || ''} + /> + +
+
+ - - - -
- ({ - value: i.name, - label: , - selectedLabel: `${i.name} (${i.ip})`, - }))} - label="Floating IP" - onChange={(name) => { - setSelectedFloatingIp( - availableFloatingIps.find((i) => i.name === name) - ) - }} - required - placeholder="Select a floating IP" - selected={selectedFloatingIp?.name || ''} - /> - -
-
- -
-
-
- - } - name="userData" - label="User Data" - control={control} - disabled={isSubmitting} - /> - -
+ > + +
+ ) } diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index a839759be..484b08798 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -61,16 +61,8 @@ test('can create an instance', async ({ page }) => { // pick a project image just to show we can await selectAProjectImage(page, 'image-3') - // should be hidden in accordion - await expectNotVisible(page, [ - 'role=radiogroup[name="Network interface"]', - 'role=textbox[name="Hostname"]', - 'text="User Data"', - ]) - - // open networking and config accordions - await page.getByRole('button', { name: 'Networking' }).click() - await page.getByRole('button', { name: 'Configuration' }).click() + // hostname field should not exist + await expectNotVisible(page, ['role=textbox[name="Hostname"]']) const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', @@ -102,7 +94,6 @@ test('can create an instance', async ({ page }) => { // Force click since there might be overlays await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) - // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() await expect(page.getByLabel('User data')).toBeVisible() @@ -146,7 +137,6 @@ test('can create an instance', async ({ page }) => { test('ephemeral pool selection tracks network interface IP version', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') - await page.getByRole('button', { name: 'Networking' }).click() const poolDropdown = page.getByLabel('Pool') await expect(poolDropdown).toBeVisible() @@ -443,7 +433,7 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip') await selectAProjectImage(page, 'image-1') - await page.getByRole('button', { name: 'Networking' }).click() + await page .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) .uncheck() @@ -463,7 +453,6 @@ test('attaches a floating IP; disables button when no IPs available', async ({ p await page.goto('/projects/mock-project/instances-new') await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectAProjectImage(page, 'image-1') - await page.getByRole('button', { name: 'Networking' }).click() await attachFloatingIpButton.click() await expect( @@ -531,7 +520,7 @@ test('attach a floating IP section has Empty version when no floating IPs exist 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') @@ -704,8 +693,7 @@ test('create instance with IPv6-only networking', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Ensure "Default" network interface is selected const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) @@ -748,8 +736,7 @@ test('create instance with IPv4-only networking', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Ensure "Default" network interface is selected const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) @@ -791,8 +778,7 @@ test('create instance with dual-stack networking shows both IPs', async ({ page await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Default is already "Default IPv4 & IPv6", so no need to select it @@ -831,8 +817,7 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() @@ -893,8 +878,7 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() @@ -955,8 +939,7 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() @@ -1013,8 +996,7 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking const ephemeralCheckbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', @@ -1095,8 +1077,7 @@ test('network interface options disabled when no VPCs exist', async ({ page }) = await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Get radio button elements const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) @@ -1119,8 +1100,7 @@ test('network interface options disabled when no VPCs exist', async ({ page }) = test('floating IPs are filtered by NIC IP version', async ({ page }) => { await page.goto('/projects/mock-project/instances-new') - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() + // Configure networking // Select IPv4-only networking const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 0c642494c..ade1a66db 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -331,8 +331,8 @@ test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion and select IPv4-only - await page.getByRole('button', { name: 'Networking' }).click() + // Select IPv4-only + const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) if (!(await defaultRadio.isChecked())) { await defaultRadio.click() @@ -386,8 +386,8 @@ test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion and select IPv6-only - await page.getByRole('button', { name: 'Networking' }).click() + // Select IPv6-only + const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) if (!(await defaultRadio.isChecked())) { await defaultRadio.click() @@ -441,8 +441,8 @@ test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion and select IPv4-only - await page.getByRole('button', { name: 'Networking' }).click() + // Select IPv4-only + const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) if (!(await defaultRadio.isChecked())) { await defaultRadio.click() @@ -490,8 +490,8 @@ test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) await selectASiloImage(page, 'ubuntu-22-04') - // Open networking accordion and select IPv6-only - await page.getByRole('button', { name: 'Networking' }).click() + // Select IPv6-only + const defaultRadio = page.getByRole('radio', { name: 'Default', exact: true }) if (!(await defaultRadio.isChecked())) { await defaultRadio.click() diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 9f5cea0be..6d68085f7 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -37,9 +37,6 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() - // Verify ephemeral IP checkbox is checked by default const ephemeralCheckbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', @@ -83,8 +80,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Open networking accordion and verify ephemeral IP defaults - await page.getByRole('button', { name: 'Networking' }).click() + // Verify ephemeral IP defaults const ephemeralCheckbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) @@ -213,9 +209,6 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() - // Verify ephemeral IP checkbox is checked by default const ephemeralCheckbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', @@ -262,9 +255,6 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() - // Verify ephemeral IP checkbox is not checked by default const ephemeralCheckbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', @@ -314,8 +304,6 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { await page.getByPlaceholder('Select a silo image', { exact: true }).click() await page.getByRole('option', { name: 'ubuntu-22-04' }).click() - await page.getByRole('button', { name: 'Networking' }).click() - const defaultRadio = page.getByRole('radio', { name: 'Default' }) await expect(defaultRadio).toBeChecked()