From 6cbbf37c33d2cb77b760e54e08a9e3acad57aa40 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 15:31:25 -0800 Subject: [PATCH 01/11] Update instance-create to handle both v4 and v6 ephemeral IP options --- app/forms/instance-create.tsx | 374 ++++++++++++++++++---------- test/e2e/instance-create.e2e.ts | 261 +++++++++++-------- test/e2e/ip-pool-silo-config.e2e.ts | 118 +++++---- 3 files changed, 468 insertions(+), 285 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 5c0941b68..ea6cafa57 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -66,7 +66,6 @@ import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' -import { Checkbox } from '~/ui/lib/Checkbox' import { toComboboxItems } from '~/ui/lib/Combobox' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -142,9 +141,11 @@ export type InstanceCreateInput = Assign< userData: File | null // ssh keys are always specified. we do not need the undefined case sshPublicKeys: NonNullable - // Pool for ephemeral IP selection - ephemeralIpPool: string - assignEphemeralIp: boolean + // Ephemeral IP fields (dual stack support) + ephemeralIpv4: boolean + ephemeralIpv4Pool: string + ephemeralIpv6: boolean + ephemeralIpv6Pool: string // Selected floating IPs to attach on create. floatingIps: NameOrId[] @@ -215,8 +216,10 @@ const baseDefaultValues: InstanceCreateInput = { start: true, userData: null, - ephemeralIpPool: '', - assignEphemeralIp: false, + ephemeralIpv4: false, + ephemeralIpv4Pool: '', + ephemeralIpv6: false, + ephemeralIpv6Pool: '', floatingIps: [], } @@ -317,10 +320,10 @@ export default function CreateInstanceForm() { const compatibleDefaultPools = unicastPools .filter(poolHasIpVersion(defaultCompatibleVersions)) .filter((p) => p.isDefault) - // TODO: when we switch to dual stack ephemeral IPs, this will need to change - // to handle selecting default pools for both v4 and v6 - const defaultEphemeralIpPool = - compatibleDefaultPools.length > 0 ? compatibleDefaultPools[0].name : '' + + // Compute defaults for dual ephemeral IP support + const hasV4Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v6') const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -328,8 +331,10 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - ephemeralIpPool: defaultEphemeralIpPool || '', - assignEphemeralIp: !!defaultEphemeralIpPool, + ephemeralIpv4: hasV4Default && defaultCompatibleVersions.includes('v4'), + ephemeralIpv4Pool: '', + ephemeralIpv6: hasV6Default && defaultCompatibleVersions.includes('v6'), + ephemeralIpv6Pool: '', floatingIps: [], } @@ -438,22 +443,42 @@ export default function CreateInstanceForm() { const bootDisk = getBootDiskAttachment(values, allImages) - const assignEphemeralIp = values.assignEphemeralIp - const ephemeralIpPool = values.ephemeralIpPool - - const externalIps: ExternalIpCreate[] = values.floatingIps.map((floatingIp) => ({ - type: 'floating' as const, - floatingIp, - })) - - if (assignEphemeralIp) { - externalIps.push({ - type: 'ephemeral', - // form validation is meant to ensure that pool is set when - // assignEphemeralIp checkbox is checked - poolSelector: { type: 'explicit', pool: ephemeralIpPool }, - }) - } + // Construct external IPs: ephemeral IPs first (v4 before v6), then floating IPs + // Only include ipVersion when requesting BOTH ephemeral IPs with auto selectors + const requestingBothEphemeralIps = values.ephemeralIpv4 && values.ephemeralIpv6 + const externalIps: ExternalIpCreate[] = [ + // v4 ephemeral if enabled (order: v4 before v6) + ...(values.ephemeralIpv4 + ? [ + { + type: 'ephemeral' as const, + poolSelector: values.ephemeralIpv4Pool + ? { type: 'explicit' as const, pool: values.ephemeralIpv4Pool } + : requestingBothEphemeralIps + ? { type: 'auto' as const, ipVersion: 'v4' as const } + : { type: 'auto' as const }, + }, + ] + : []), + // v6 ephemeral if enabled + ...(values.ephemeralIpv6 + ? [ + { + type: 'ephemeral' as const, + poolSelector: values.ephemeralIpv6Pool + ? { type: 'explicit' as const, pool: values.ephemeralIpv6Pool } + : requestingBothEphemeralIps + ? { type: 'auto' as const, ipVersion: 'v6' as const } + : { type: 'auto' as const }, + }, + ] + : []), + // floating IPs (can coexist with ephemeral) + ...values.floatingIps.map((floatingIp) => ({ + type: 'floating' as const, + floatingIp, + })), + ] const userData = values.userData ? await readBlobAsBase64(values.userData) @@ -775,14 +800,17 @@ const NetworkingSection = ({ const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) const [selectedFloatingIp, setSelectedFloatingIp] = useState() - const assignEphemeralIpField = useController({ control, name: 'assignEphemeralIp' }) + const ephemeralIpv4Field = useController({ control, name: 'ephemeralIpv4' }) + const ephemeralIpv4PoolField = useController({ control, name: 'ephemeralIpv4Pool' }) + const ephemeralIpv6Field = useController({ control, name: 'ephemeralIpv6' }) + const ephemeralIpv6PoolField = useController({ control, name: 'ephemeralIpv6Pool' }) const floatingIpsField = useController({ control, name: 'floatingIps' }) - const assignEphemeralIp = assignEphemeralIpField.field.value - const attachedFloatingIps = floatingIpsField.field.value ?? EMPTY_NAME_OR_ID_LIST - const ephemeralIpPoolField = useController({ control, name: 'ephemeralIpPool' }) - - const ephemeralIpPool = ephemeralIpPoolField.field.value + const ephemeralIpv4 = ephemeralIpv4Field.field.value + const ephemeralIpv4Pool = ephemeralIpv4PoolField.field.value + const ephemeralIpv6 = ephemeralIpv6Field.field.value + const ephemeralIpv6Pool = ephemeralIpv6PoolField.field.value + const attachedFloatingIps = floatingIpsField.field.value ?? EMPTY_NAME_OR_ID_LIST // Calculate compatible IP versions based on NIC type const compatibleVersions = useMemo( @@ -790,6 +818,23 @@ const NetworkingSection = ({ [networkInterfaces] ) + // Filter pools by compatibility and version + const compatiblePools = useMemo( + () => unicastPools.filter(poolHasIpVersion(compatibleVersions)), + [unicastPools, compatibleVersions] + ) + const v4Pools = useMemo( + () => compatiblePools.filter((p) => p.ipVersion === 'v4'), + [compatiblePools] + ) + const v6Pools = useMemo( + () => compatiblePools.filter((p) => p.ipVersion === 'v6'), + [compatiblePools] + ) + + const canAttachV4 = compatibleVersions.includes('v4') && v4Pools.length > 0 + const canAttachV6 = compatibleVersions.includes('v6') && v6Pools.length > 0 + const { project } = useProjectSelector() const { data: floatingIpList } = usePrefetchedQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) @@ -813,86 +858,128 @@ const NetworkingSection = ({ .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) .filter((ip) => !!ip) - // Filter unicast pools by compatible IP versions - // unicastPools is already sorted (defaults first, v4 first, then by name), - // so filtering preserves that order - const compatiblePools = useMemo( - () => unicastPools.filter(poolHasIpVersion(compatibleVersions)), - [unicastPools, compatibleVersions] - ) + // IPv4 pool auto-selection + useEffect(() => { + if (!ephemeralIpv4 || v4Pools.length === 0) return + // If current pool is valid, keep it + if (ephemeralIpv4Pool && v4Pools.some((p) => p.name === ephemeralIpv4Pool)) return + + // Otherwise, try to select default + const v4Default = v4Pools.find((p) => p.isDefault) + if (v4Default) { + ephemeralIpv4PoolField.field.onChange(v4Default.name) + } else { + // No default: clear the pool (user must select) + ephemeralIpv4PoolField.field.onChange('') + } + }, [ephemeralIpv4, ephemeralIpv4Pool, ephemeralIpv4PoolField, v4Pools]) + + // IPv6 pool auto-selection useEffect(() => { - if (!assignEphemeralIp || compatiblePools.length === 0) return + if (!ephemeralIpv6 || v6Pools.length === 0) return - const currentPoolValid = - ephemeralIpPool && compatiblePools.some((p) => p.name === ephemeralIpPool) - if (currentPoolValid) return + if (ephemeralIpv6Pool && v6Pools.some((p) => p.name === ephemeralIpv6Pool)) return - const defaultPool = compatiblePools.find((p) => p.isDefault) - if (defaultPool) { - ephemeralIpPoolField.field.onChange(defaultPool.name) + const v6Default = v6Pools.find((p) => p.isDefault) + if (v6Default) { + ephemeralIpv6PoolField.field.onChange(v6Default.name) } else { - ephemeralIpPoolField.field.onChange('') + ephemeralIpv6PoolField.field.onChange('') } - }, [assignEphemeralIp, ephemeralIpPool, ephemeralIpPoolField, compatiblePools]) + }, [ephemeralIpv6, ephemeralIpv6Pool, ephemeralIpv6PoolField, v6Pools]) + + // Clean up incompatible ephemeral IP selections when NIC changes + useEffect(() => { + // When NIC changes, uncheck and clear incompatible options + if (ephemeralIpv4 && !compatibleVersions.includes('v4')) { + ephemeralIpv4Field.field.onChange(false) + ephemeralIpv4PoolField.field.onChange('') + } + if (ephemeralIpv6 && !compatibleVersions.includes('v6')) { + ephemeralIpv6Field.field.onChange(false) + ephemeralIpv6PoolField.field.onChange('') + } + }, [ + compatibleVersions, + ephemeralIpv4, + ephemeralIpv4Field, + ephemeralIpv4PoolField, + ephemeralIpv6, + ephemeralIpv6Field, + ephemeralIpv6PoolField, + ]) - // Track previous ability to attach ephemeral IP to detect transitions - const prevCanAttachRef = useRef(undefined) + // Track previous canAttach state to detect transitions + const prevCanAttachV4Ref = useRef(undefined) + const prevCanAttachV6Ref = useRef(undefined) - // Automatically manage ephemeral IP based on NIC and pool availability + // Auto-enable ephemeral IPs when NICs are added that support them useEffect(() => { - const hasCompatibleNics = compatibleVersions.length > 0 - const hasPools = compatiblePools.length > 0 - const canAttach = hasCompatibleNics && hasPools - const hasDefaultPool = compatiblePools.some((p) => p.isDefault) - const prevCanAttach = prevCanAttachRef.current - - if (!canAttach && assignEphemeralIp) { - // Remove ephemeral IP when there are no compatible NICs or pools - assignEphemeralIpField.field.onChange(false) - } else if ( - canAttach && - hasDefaultPool && - prevCanAttach === false && - !assignEphemeralIp - ) { - // Add ephemeral IP when transitioning from unable to able to attach - // (prevCanAttach === false means we couldn't attach before, either due to no NICs or no pools) - assignEphemeralIpField.field.onChange(true) + const prevCanAttachV4 = prevCanAttachV4Ref.current + const hasV4Default = v4Pools.some((p) => p.isDefault) + + // Auto-enable v4 when transitioning from unable to able (e.g., NIC added) + if (canAttachV4 && hasV4Default && prevCanAttachV4 === false && !ephemeralIpv4) { + ephemeralIpv4Field.field.onChange(true) } - prevCanAttachRef.current = canAttach - }, [assignEphemeralIp, assignEphemeralIpField, compatiblePools, compatibleVersions]) + prevCanAttachV4Ref.current = canAttachV4 + }, [canAttachV4, v4Pools, ephemeralIpv4, ephemeralIpv4Field]) - const ephemeralIpCheckboxState = useMemo(() => { - const hasCompatibleNics = compatibleVersions.length > 0 - const hasCompatiblePools = compatiblePools.length > 0 - const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools + useEffect(() => { + const prevCanAttachV6 = prevCanAttachV6Ref.current + const hasV6Default = v6Pools.some((p) => p.isDefault) - let disabledReason: React.ReactNode = undefined - if (!hasCompatibleNics) { - disabledReason = ( - <> - Add a compatible network interface -
- to attach an ephemeral IP address - - ) - } else if (!hasCompatiblePools) { - // TODO: "compatible" not clear enough. also this can happen if there are - // no pools at all as well as when there are no pools compatible withe - // the NIC stack. We could do a different messages for each. - disabledReason = ( - <> - No compatible IP pools available -
- for this network interface type - - ) + // Auto-enable v6 when transitioning from unable to able (e.g., NIC added) + if (canAttachV6 && hasV6Default && prevCanAttachV6 === false && !ephemeralIpv6) { + ephemeralIpv6Field.field.onChange(true) } - return { canAttachEphemeralIp, disabledReason } - }, [compatibleVersions, compatiblePools]) + prevCanAttachV6Ref.current = canAttachV6 + }, [canAttachV6, v6Pools, ephemeralIpv6, ephemeralIpv6Field]) + + const noNicMessage = (version: IpVersion) => ( + <> + Add an IP{version} network interface +
+ to attach an ephemeral IP{version} address + + ) + const noPoolMessage = (version: IpVersion) => ( + <> + No IP{version} pools available +
+ for this instance’s network interfaces + + ) + + const getDisabledReason = ( + canAttach: boolean, + version: IpVersion, + compatibleVersions: IpVersion[], + pools: UnicastIpPool[] + ): React.ReactNode => { + if (canAttach) return undefined + if (!compatibleVersions.includes(version)) { + return noNicMessage(version) + } + if (pools.length === 0) { + return noPoolMessage(version) + } + return undefined + } + + // Calculate disabled reasons for ephemeral IP checkboxes + const v4DisabledReason = useMemo( + () => getDisabledReason(canAttachV4, 'v4', compatibleVersions, v4Pools), + [canAttachV4, compatibleVersions, v4Pools] + ) + + const v6DisabledReason = useMemo( + () => getDisabledReason(canAttachV6, 'v6', compatibleVersions, v6Pools), + [canAttachV6, compatibleVersions, v6Pools] + ) const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) @@ -922,6 +1009,47 @@ const NetworkingSection = ({ ) + // Helper component to reduce duplication between IPv4 and IPv6 ephemeral IP sections + const EphemeralIpCheckbox = ({ + ipVersion, + checked, + pools, + canAttach, + disabledReason, + }: { + ipVersion: IpVersion + checked: boolean + pools: UnicastIpPool[] + canAttach: boolean + disabledReason: React.ReactNode + }) => { + const checkboxName = ipVersion === 'v4' ? 'ephemeralIpv4' : 'ephemeralIpv6' + const poolFieldName = ipVersion === 'v4' ? 'ephemeralIpv4Pool' : 'ephemeralIpv6Pool' + const displayVersion = `IP${ipVersion}` + + return ( +
+ }> + + + Allocate and attach ephemeral {displayVersion} address + + + + {checked && ( +
+ +
+ )} +
+ ) + } + return ( <> {!hasVpcs && ( @@ -947,38 +1075,22 @@ const NetworkingSection = ({ - } - > - {/* 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 - - - - +
+ + +
diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 484b08798..8c36c1943 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -64,35 +64,26 @@ test('can create an instance', async ({ page }) => { // 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', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - const poolDropdown = page.getByLabel('Pool') - - // verify that the ephemeral IP checkbox is checked and pool dropdown is visible - await expect(checkbox).toBeChecked() - await expect(poolDropdown).toBeVisible() - - // IPv4 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-1') - // click the dropdown to open it and verify options are available - await poolDropdown.click() - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() + // verify that the IPv4 ephemeral IP checkbox is checked by default + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeChecked() - // unchecking the box should hide the pool selector - await checkbox.uncheck() - await expect(poolDropdown).toBeHidden() + // IPv4 default pool should be selected + const v4PoolDropdown = page.getByLabel('Pool').first() + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') - // re-checking the box should re-enable the selector, and other options should be selectable - await checkbox.check() - // Need to wait for the dropdown to be visible first - await expect(poolDropdown).toBeVisible() - // Click the dropdown to open it and wait for options to be available - await poolDropdown.click() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() - // Force click since there might be overlays - await page.getByRole('option', { name: 'ip-pool-2' }).click({ force: true }) + // IPv6 default pool should be selected + const v6PoolDropdown = page.getByLabel('Pool').last() + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() await expect(page.getByLabel('User data')).toBeVisible() @@ -138,15 +129,38 @@ 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') - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-1') + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', + }) - await selectOption(page, page.getByRole('button', { name: 'IPv4 & IPv6' }), 'IPv6') - await expect(poolDropdown).toContainText('ip-pool-2') + // Default NIC is dual-stack, both checkboxes should be visible, enabled, and checked + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + // Change to IPv6-only NIC - v4 checkbox should become disabled and unchecked + await selectOption(page, page.getByRole('button', { name: 'IPv4 & IPv6' }), 'IPv6') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + + // Change to IPv4-only NIC - v6 checkbox should become disabled and unchecked await selectOption(page, page.getByRole('button', { name: 'IPv6' }), 'IPv4') - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() }) test('duplicate instance name produces visible error', async ({ page }) => { @@ -434,9 +448,14 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip') await selectAProjectImage(page, 'image-1') + // Uncheck both ephemeral IP checkboxes await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .getByRole('checkbox', { name: 'Allocate and attach ephemeral IPv4 address' }) .uncheck() + await page + .getByRole('checkbox', { name: 'Allocate and attach ephemeral IPv6 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() @@ -846,27 +865,28 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' nicTable.getByRole('cell', { name: 'my-ipv4-nic', exact: true }) ).toBeVisible() - // Verify that ephemeral IP options are constrained to IPv4 only - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that only IPv4 ephemeral IP is enabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - // IPv4 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() - // Open dropdown to check available options - IPv6 pools should be filtered out - await poolDropdown.click() + // IPv4 pool dropdown should be visible with default selected + const v4PoolDropdown = page.getByLabel('Pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') - // ip-pool-1 is IPv4, should appear + // Open dropdown to check available options - only IPv4 pools should appear + await v4PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() - - // ip-pool-2 is IPv6, should NOT appear - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() }) test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6', async ({ @@ -907,27 +927,28 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' nicTable.getByRole('cell', { name: 'my-ipv6-nic', exact: true }) ).toBeVisible() - // Verify that ephemeral IP options are constrained to IPv6 only - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that only IPv6 ephemeral IP is enabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - // IPv6 default pool should be selected by default - await expect(poolDropdown).toContainText('ip-pool-2') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() - // Open dropdown to check available options - IPv4 pools should be filtered out - await poolDropdown.click() + // IPv6 pool dropdown should be visible with default selected + const v6PoolDropdown = page.getByLabel('Pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') - // ip-pool-2 is IPv6, should appear + // Open dropdown to check available options - only IPv6 pools should appear + await v6PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() - - // ip-pool-1 is IPv4, should NOT appear - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() }) test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephemeral IPs', async ({ @@ -968,25 +989,44 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem nicTable.getByRole('cell', { name: 'my-dual-stack-nic', exact: true }) ).toBeVisible() - // Verify that both IPv4 and IPv6 ephemeral IP options are available - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify that both IPv4 and IPv6 ephemeral IP checkboxes are available + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).toBeVisible() - // Pool dropdown should be visible - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeChecked() - // IPv4 default pool should be selected by default (first in sorted order) - await expect(poolDropdown).toContainText('ip-pool-1') + // Both pool dropdowns should be visible with defaults selected + const poolDropdowns = page.getByLabel('Pool') + await expect(poolDropdowns.first()).toBeVisible() + await expect(poolDropdowns.first()).toContainText('ip-pool-1') + await expect(poolDropdowns.last()).toBeVisible() + await expect(poolDropdowns.last()).toContainText('ip-pool-2') + + // Create the instance + await page.getByRole('button', { name: 'Create instance' }).click() - // Open dropdown to check available options - both IPv4 and IPv6 pools should be available - await poolDropdown.click() + // Should navigate to instance page + await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) - // Both pools should appear - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Verify two ephemeral IP rows exist in the external IPs table + const externalIpsTable = page.getByRole('table', { name: /external ips/i }) + const ephemeralRows = externalIpsTable.getByRole('row').filter({ hasText: 'ephemeral' }) + + await expect(ephemeralRows).toHaveCount(2) + + // Verify one is IPv4 and one is IPv6 + await expect(externalIpsTable.getByText('IPv4')).toBeVisible() + await expect(externalIpsTable.getByText('IPv6')).toBeVisible() }) test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) => { @@ -998,8 +1038,11 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Configure networking - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) const defaultRadio = page.getByRole('radio', { name: 'Default', @@ -1008,25 +1051,32 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }).first() - // Verify default state: "Default" is checked and Ephemeral IP checkbox is checked + // Verify default state: "Default" is checked and both ephemeral IP checkboxes are visible, enabled, and checked await expect(defaultRadio).toBeChecked() - await expect(ephemeralCheckbox).toBeChecked() - await expect(ephemeralCheckbox).toBeEnabled() - - // Select "None" radio → verify Ephemeral IP checkbox is unchecked and disabled + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeEnabled() + await expect(v6Checkbox).toBeChecked() + + // Select "None" radio → verify ephemeral IP checkboxes are disabled and unchecked await noneRadio.click() - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() - - // Hover over the disabled checkbox to verify tooltip appears - await ephemeralCheckbox.hover() - await expect(page.getByText('Add a compatible network interface')).toBeVisible() - await expect(page.getByText('to attach an ephemeral IP address')).toBeVisible() - - // Select "Custom" radio → verify Ephemeral IP checkbox is still unchecked and disabled + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() + + // Select "Custom" radio → verify ephemeral IP checkboxes are still disabled and unchecked await customRadio.click() - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() // Click "Add network interface" button to open modal await page.getByRole('button', { name: 'Add network interface' }).click() @@ -1054,9 +1104,12 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true }) ).toBeVisible() - // Verify Ephemeral IP checkbox is now checked and enabled - await expect(ephemeralCheckbox).toBeChecked() - await expect(ephemeralCheckbox).toBeEnabled() + // Verify IPv4 ephemeral IP checkbox is now enabled and checked (auto-enabled when NIC added) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeEnabled() + await expect(v4Checkbox).toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() // Delete the NIC using the remove button await page.getByRole('button', { name: 'remove network interface new-v4-nic' }).click() @@ -1064,9 +1117,13 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Verify the NIC is no longer in the table await expect(nicTable.getByRole('cell', { name: 'new-v4-nic', exact: true })).toBeHidden() - // Verify Ephemeral IP checkbox is once again unchecked and disabled - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() + // Verify ephemeral IP checkboxes are disabled and unchecked again + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() + await expect(v6Checkbox).not.toBeChecked() }) test('network interface options disabled when no VPCs exist', async ({ page }) => { diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 6d68085f7..4118b7339 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -37,24 +37,29 @@ 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() - // Verify ephemeral IP checkbox is checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify IPv4 ephemeral IP checkbox is checked by default + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).toBeChecked() - // Pool dropdown should be visible with IPv4 pool preselected - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeChecked() + // v6 checkbox should be visible but not checked (no v6 default in myriad silo) + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).not.toBeChecked() + + // IPv4 pool dropdown should be visible with default pool preselected + const v4PoolDropdown = page.getByLabel('Pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') // Open dropdown to verify available options (only v4 pools should be available) - await poolDropdown.click() + await v4PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() await expect(page.getByRole('option', { name: 'ip-pool-3' })).toBeVisible() - // IPv6 pools should not be available in this silo - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() - await expect(page.getByRole('option', { name: 'ip-pool-4' })).toBeHidden() }) test('floating IP create form shows IPv4 default pool preselected', async ({ @@ -81,10 +86,10 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await page.getByRole('option', { name: 'ubuntu-22-04' }).click() // Verify ephemeral IP defaults - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', }) - await expect(ephemeralCheckbox).toBeChecked() + await expect(v4Checkbox).toBeChecked() await expect(page.getByLabel('Pool')).toContainText('ip-pool-1') // Create instance @@ -209,24 +214,29 @@ 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() - // Verify ephemeral IP checkbox is checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify IPv6 ephemeral IP checkbox is checked by default + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).toBeChecked() - // Pool dropdown should be visible with IPv6 pool preselected - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeVisible() - await expect(poolDropdown).toContainText('ip-pool-2') + // v4 checkbox should be visible but not checked (no v4 default in thrax silo) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeChecked() + + // IPv6 pool dropdown should be visible with default pool preselected + const v6PoolDropdown = page.getByLabel('Pool') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') // Open dropdown to verify available options (only v6 pools should be available) - await poolDropdown.click() + await v6PoolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() await expect(page.getByRole('option', { name: 'ip-pool-4' })).toBeVisible() - // IPv4 pools should not be available in this silo - await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() - await expect(page.getByRole('option', { name: 'ip-pool-3' })).toBeHidden() }) test('floating IP create form shows IPv6 default pool preselected', async ({ @@ -255,26 +265,32 @@ 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() - // Verify ephemeral IP checkbox is not checked by default - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // Verify ephemeral IP checkboxes are not checked by default (no defaults in pelerines silo) + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', }) - await expect(ephemeralCheckbox).not.toBeChecked() - // Pool dropdown should not be shown unless ephemeral IP is enabled. - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeHidden() + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).not.toBeChecked() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).not.toBeChecked() + + // Pool dropdowns should not be shown unless ephemeral IPs are enabled + const poolDropdowns = page.getByLabel('Pool') + await expect(poolDropdowns.first()).toBeHidden() - // Enabling ephemeral IP should allow selecting from available pools. - await ephemeralCheckbox.click() - await expect(ephemeralCheckbox).toBeChecked() - await expect(poolDropdown).toBeVisible() + // Enabling IPv4 ephemeral IP should show pool dropdown + await v4Checkbox.click() + await expect(v4Checkbox).toBeChecked() + await expect(poolDropdowns.first()).toBeVisible() // Open dropdown to verify available options - await poolDropdown.click() + await poolDropdowns.first().click() // Both pools are linked to this silo but neither is default await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() }) test('floating IP create form handles missing default pool gracefully', async ({ @@ -307,19 +323,17 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { const defaultRadio = page.getByRole('radio', { name: 'Default' }) await expect(defaultRadio).toBeChecked() - const ephemeralCheckbox = page.getByRole('checkbox', { - name: 'Allocate and attach an ephemeral IP address', + // When there are no pools, both checkboxes should be visible but disabled + const v4Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv4 address', }) - await expect(ephemeralCheckbox).not.toBeChecked() - await expect(ephemeralCheckbox).toBeDisabled() - - await ephemeralCheckbox.hover() - await expect( - page.getByRole('tooltip').filter({ hasText: /No compatible IP pools available/ }) - ).toBeVisible() - - const poolDropdown = page.getByLabel('Pool') - await expect(poolDropdown).toBeHidden() + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach ephemeral IPv6 address', + }) + await expect(v4Checkbox).toBeVisible() + await expect(v4Checkbox).toBeDisabled() + await expect(v6Checkbox).toBeVisible() + await expect(v6Checkbox).toBeDisabled() const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) const dialog = page.getByRole('dialog') From acd97a4ae4ec638d867f7f491a8d80b80eaa6613 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 16:47:17 -0800 Subject: [PATCH 02/11] Update copy; smarter handling of compatible defaults --- app/forms/instance-create.tsx | 26 +++++++++++++++++--------- test/e2e/instance-create.e2e.ts | 28 ++++++++++++++-------------- test/e2e/ip-pool-silo-config.e2e.ts | 18 +++++++++--------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index ea6cafa57..beaaeb11d 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -322,8 +322,15 @@ export default function CreateInstanceForm() { .filter((p) => p.isDefault) // Compute defaults for dual ephemeral IP support - const hasV4Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v6') + // Check ALL default pools in silo (not just compatible ones) to determine + // if ipVersion is needed in auto selectors (API requires it when multiple defaults exist) + const allDefaultPools = unicastPools.filter((p) => p.isDefault) + const hasV4Default = allDefaultPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = allDefaultPools.some((p) => p.ipVersion === 'v6') + + // Check compatible defaults for checkbox initialization + const hasCompatibleV4Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v4') + const hasCompatibleV6Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v6') const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -331,9 +338,9 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - ephemeralIpv4: hasV4Default && defaultCompatibleVersions.includes('v4'), + ephemeralIpv4: hasCompatibleV4Default && defaultCompatibleVersions.includes('v4'), ephemeralIpv4Pool: '', - ephemeralIpv6: hasV6Default && defaultCompatibleVersions.includes('v6'), + ephemeralIpv6: hasCompatibleV6Default && defaultCompatibleVersions.includes('v6'), ephemeralIpv6Pool: '', floatingIps: [], } @@ -444,8 +451,9 @@ export default function CreateInstanceForm() { const bootDisk = getBootDiskAttachment(values, allImages) // Construct external IPs: ephemeral IPs first (v4 before v6), then floating IPs - // Only include ipVersion when requesting BOTH ephemeral IPs with auto selectors - const requestingBothEphemeralIps = values.ephemeralIpv4 && values.ephemeralIpv6 + // Include ipVersion when using auto selectors IF multiple default pools exist + // (API requires ipVersion to disambiguate when both v4 and v6 defaults exist) + const multipleDefaultPools = hasV4Default && hasV6Default const externalIps: ExternalIpCreate[] = [ // v4 ephemeral if enabled (order: v4 before v6) ...(values.ephemeralIpv4 @@ -454,7 +462,7 @@ export default function CreateInstanceForm() { type: 'ephemeral' as const, poolSelector: values.ephemeralIpv4Pool ? { type: 'explicit' as const, pool: values.ephemeralIpv4Pool } - : requestingBothEphemeralIps + : multipleDefaultPools ? { type: 'auto' as const, ipVersion: 'v4' as const } : { type: 'auto' as const }, }, @@ -467,7 +475,7 @@ export default function CreateInstanceForm() { type: 'ephemeral' as const, poolSelector: values.ephemeralIpv6Pool ? { type: 'explicit' as const, pool: values.ephemeralIpv6Pool } - : requestingBothEphemeralIps + : multipleDefaultPools ? { type: 'auto' as const, ipVersion: 'v6' as const } : { type: 'auto' as const }, }, @@ -1032,7 +1040,7 @@ const NetworkingSection = ({ }> - Allocate and attach ephemeral {displayVersion} address + Allocate and attach an ephemeral {displayVersion} address diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 8c36c1943..7e21b58c2 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -65,10 +65,10 @@ test('can create an instance', async ({ page }) => { await expectNotVisible(page, ['role=textbox[name="Hostname"]']) const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) // verify that the IPv4 ephemeral IP checkbox is checked by default @@ -130,10 +130,10 @@ test('ephemeral pool selection tracks network interface IP version', async ({ pa await page.goto('/projects/mock-project/instances-new') const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) // Default NIC is dual-stack, both checkboxes should be visible, enabled, and checked @@ -450,10 +450,10 @@ test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ // Uncheck both ephemeral IP checkboxes await page - .getByRole('checkbox', { name: 'Allocate and attach ephemeral IPv4 address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv4 address' }) .uncheck() await page - .getByRole('checkbox', { name: 'Allocate and attach ephemeral IPv6 address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv6 address' }) .uncheck() await page.getByRole('button', { name: 'Create instance' }).click() @@ -867,10 +867,10 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' // Verify that only IPv4 ephemeral IP is enabled const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() @@ -929,10 +929,10 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' // Verify that only IPv6 ephemeral IP is enabled const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() @@ -991,10 +991,10 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem // Verify that both IPv4 and IPv6 ephemeral IP checkboxes are available const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() @@ -1039,10 +1039,10 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Configure networking const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) const defaultRadio = page.getByRole('radio', { name: 'Default', diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 4118b7339..f016b33db 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -39,10 +39,10 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { // Verify IPv4 ephemeral IP checkbox is checked by default const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() @@ -87,7 +87,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { // Verify ephemeral IP defaults const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) await expect(v4Checkbox).toBeChecked() await expect(page.getByLabel('Pool')).toContainText('ip-pool-1') @@ -216,10 +216,10 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { // Verify IPv6 ephemeral IP checkbox is checked by default const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) // v4 checkbox should be visible but not checked (no v4 default in thrax silo) @@ -267,10 +267,10 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { // Verify ephemeral IP checkboxes are not checked by default (no defaults in pelerines silo) const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() @@ -325,10 +325,10 @@ test.describe('IP pool configuration: no-pools silo (no IP pools)', () => { // When there are no pools, both checkboxes should be visible but disabled const v4Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv4 address', + name: 'Allocate and attach an ephemeral IPv4 address', }) const v6Checkbox = page.getByRole('checkbox', { - name: 'Allocate and attach ephemeral IPv6 address', + name: 'Allocate and attach an ephemeral IPv6 address', }) await expect(v4Checkbox).toBeVisible() await expect(v4Checkbox).toBeDisabled() From badd541575dba0c325d96ae88a750e906b622999 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 17:11:48 -0800 Subject: [PATCH 03/11] refactor/bugfix --- app/forms/instance-create.tsx | 22 +++++----------------- test/e2e/instance-networking.e2e.ts | 8 ++++---- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index beaaeb11d..c1a0dcfac 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -321,14 +321,7 @@ export default function CreateInstanceForm() { .filter(poolHasIpVersion(defaultCompatibleVersions)) .filter((p) => p.isDefault) - // Compute defaults for dual ephemeral IP support - // Check ALL default pools in silo (not just compatible ones) to determine - // if ipVersion is needed in auto selectors (API requires it when multiple defaults exist) - const allDefaultPools = unicastPools.filter((p) => p.isDefault) - const hasV4Default = allDefaultPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = allDefaultPools.some((p) => p.ipVersion === 'v6') - - // Check compatible defaults for checkbox initialization + // Check for compatible defaults to determine checkbox initialization const hasCompatibleV4Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v4') const hasCompatibleV6Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v6') @@ -451,9 +444,8 @@ export default function CreateInstanceForm() { const bootDisk = getBootDiskAttachment(values, allImages) // Construct external IPs: ephemeral IPs first (v4 before v6), then floating IPs - // Include ipVersion when using auto selectors IF multiple default pools exist - // (API requires ipVersion to disambiguate when both v4 and v6 defaults exist) - const multipleDefaultPools = hasV4Default && hasV6Default + // Always include ipVersion in auto selectors to match user intent and satisfy + // API requirements (dual-stack requests MUST specify ipVersion on each) const externalIps: ExternalIpCreate[] = [ // v4 ephemeral if enabled (order: v4 before v6) ...(values.ephemeralIpv4 @@ -462,9 +454,7 @@ export default function CreateInstanceForm() { type: 'ephemeral' as const, poolSelector: values.ephemeralIpv4Pool ? { type: 'explicit' as const, pool: values.ephemeralIpv4Pool } - : multipleDefaultPools - ? { type: 'auto' as const, ipVersion: 'v4' as const } - : { type: 'auto' as const }, + : { type: 'auto' as const, ipVersion: 'v4' as const }, }, ] : []), @@ -475,9 +465,7 @@ export default function CreateInstanceForm() { type: 'ephemeral' as const, poolSelector: values.ephemeralIpv6Pool ? { type: 'explicit' as const, pool: values.ephemeralIpv6Pool } - : multipleDefaultPools - ? { type: 'auto' as const, ipVersion: 'v6' as const } - : { type: 'auto' as const }, + : { type: 'auto' as const, ipVersion: 'v6' as const }, }, ] : []), diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index ade1a66db..1b70ac192 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -344,7 +344,7 @@ test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { // Don't attach ephemeral IP at creation await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv4 address' }) .uncheck() // Create instance @@ -399,7 +399,7 @@ test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { // Don't attach ephemeral IP at creation await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv6 address' }) .uncheck() // Create instance @@ -454,7 +454,7 @@ test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { // Don't attach ephemeral IP at creation await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv4 address' }) .uncheck() // Create instance @@ -503,7 +503,7 @@ test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { // Don't attach ephemeral IP at creation await page - .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IPv6 address' }) .uncheck() // Create instance From b1cbf124057e175dea281379a0779ae4084aaebc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 20:57:11 -0800 Subject: [PATCH 04/11] remove useEffects --- app/components/form/fields/IpPoolSelector.tsx | 3 ++ app/forms/instance-create.tsx | 51 ++++--------------- 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index f18e42965..f677b1c62 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -54,6 +54,7 @@ type IpPoolSelectorProps< /** Compatible IP versions based on network interface type */ compatibleVersions?: IpVersion[] required?: boolean + hideOptionalTag?: boolean } export function IpPoolSelector< @@ -67,6 +68,7 @@ export function IpPoolSelector< disabled = false, compatibleVersions = ALL_IP_VERSIONS, required = true, + hideOptionalTag = false, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component const sortedPools = useMemo(() => { @@ -90,6 +92,7 @@ export function IpPoolSelector< placeholder="Select a pool" required={required} disabled={disabled} + hideOptionalTag={hideOptionalTag} />
) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index c1a0dcfac..2a555b758 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -321,9 +321,9 @@ export default function CreateInstanceForm() { .filter(poolHasIpVersion(defaultCompatibleVersions)) .filter((p) => p.isDefault) - // Check for compatible defaults to determine checkbox initialization - const hasCompatibleV4Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v4') - const hasCompatibleV6Default = compatibleDefaultPools.some((p) => p.ipVersion === 'v6') + // Get default pools for initial values + const defaultV4Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v4') + const defaultV6Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v6') const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -331,10 +331,10 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - ephemeralIpv4: hasCompatibleV4Default && defaultCompatibleVersions.includes('v4'), - ephemeralIpv4Pool: '', - ephemeralIpv6: hasCompatibleV6Default && defaultCompatibleVersions.includes('v6'), - ephemeralIpv6Pool: '', + ephemeralIpv4: !!defaultV4Pool && defaultCompatibleVersions.includes('v4'), + ephemeralIpv4Pool: defaultV4Pool?.name || '', + ephemeralIpv6: !!defaultV6Pool && defaultCompatibleVersions.includes('v6'), + ephemeralIpv6Pool: defaultV6Pool?.name || '', floatingIps: [], } @@ -803,9 +803,7 @@ const NetworkingSection = ({ const floatingIpsField = useController({ control, name: 'floatingIps' }) const ephemeralIpv4 = ephemeralIpv4Field.field.value - const ephemeralIpv4Pool = ephemeralIpv4PoolField.field.value const ephemeralIpv6 = ephemeralIpv6Field.field.value - const ephemeralIpv6Pool = ephemeralIpv6PoolField.field.value const attachedFloatingIps = floatingIpsField.field.value ?? EMPTY_NAME_OR_ID_LIST // Calculate compatible IP versions based on NIC type @@ -854,37 +852,6 @@ const NetworkingSection = ({ .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) .filter((ip) => !!ip) - // IPv4 pool auto-selection - useEffect(() => { - if (!ephemeralIpv4 || v4Pools.length === 0) return - - // If current pool is valid, keep it - if (ephemeralIpv4Pool && v4Pools.some((p) => p.name === ephemeralIpv4Pool)) return - - // Otherwise, try to select default - const v4Default = v4Pools.find((p) => p.isDefault) - if (v4Default) { - ephemeralIpv4PoolField.field.onChange(v4Default.name) - } else { - // No default: clear the pool (user must select) - ephemeralIpv4PoolField.field.onChange('') - } - }, [ephemeralIpv4, ephemeralIpv4Pool, ephemeralIpv4PoolField, v4Pools]) - - // IPv6 pool auto-selection - useEffect(() => { - if (!ephemeralIpv6 || v6Pools.length === 0) return - - if (ephemeralIpv6Pool && v6Pools.some((p) => p.name === ephemeralIpv6Pool)) return - - const v6Default = v6Pools.find((p) => p.isDefault) - if (v6Default) { - ephemeralIpv6PoolField.field.onChange(v6Default.name) - } else { - ephemeralIpv6PoolField.field.onChange('') - } - }, [ephemeralIpv6, ephemeralIpv6Pool, ephemeralIpv6PoolField, v6Pools]) - // Clean up incompatible ephemeral IP selections when NIC changes useEffect(() => { // When NIC changes, uncheck and clear incompatible options @@ -906,7 +873,7 @@ const NetworkingSection = ({ ephemeralIpv6PoolField, ]) - // Track previous canAttach state to detect transitions + // Track previous canAttach state to detect transitions for auto-enabling const prevCanAttachV4Ref = useRef(undefined) const prevCanAttachV6Ref = useRef(undefined) @@ -1039,6 +1006,8 @@ const NetworkingSection = ({ poolFieldName={poolFieldName} pools={pools} disabled={isSubmitting} + required={false} + hideOptionalTag /> )} From b10cf36842f17bb8f00b0e563b2bfd6a5d53c7b9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 21:11:29 -0800 Subject: [PATCH 05/11] A bit more logic around custom NICs --- app/forms/instance-create.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 2a555b758..f5db54b70 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -880,27 +880,31 @@ const NetworkingSection = ({ // Auto-enable ephemeral IPs when NICs are added that support them useEffect(() => { const prevCanAttachV4 = prevCanAttachV4Ref.current - const hasV4Default = v4Pools.some((p) => p.isDefault) + const v4Default = v4Pools.find((p) => p.isDefault) // Auto-enable v4 when transitioning from unable to able (e.g., NIC added) - if (canAttachV4 && hasV4Default && prevCanAttachV4 === false && !ephemeralIpv4) { + if (canAttachV4 && v4Default && prevCanAttachV4 === false && !ephemeralIpv4) { ephemeralIpv4Field.field.onChange(true) + // Also populate the pool field with the default + ephemeralIpv4PoolField.field.onChange(v4Default.name) } prevCanAttachV4Ref.current = canAttachV4 - }, [canAttachV4, v4Pools, ephemeralIpv4, ephemeralIpv4Field]) + }, [canAttachV4, v4Pools, ephemeralIpv4, ephemeralIpv4Field, ephemeralIpv4PoolField]) useEffect(() => { const prevCanAttachV6 = prevCanAttachV6Ref.current - const hasV6Default = v6Pools.some((p) => p.isDefault) + const v6Default = v6Pools.find((p) => p.isDefault) // Auto-enable v6 when transitioning from unable to able (e.g., NIC added) - if (canAttachV6 && hasV6Default && prevCanAttachV6 === false && !ephemeralIpv6) { + if (canAttachV6 && v6Default && prevCanAttachV6 === false && !ephemeralIpv6) { ephemeralIpv6Field.field.onChange(true) + // Also populate the pool field with the default + ephemeralIpv6PoolField.field.onChange(v6Default.name) } prevCanAttachV6Ref.current = canAttachV6 - }, [canAttachV6, v6Pools, ephemeralIpv6, ephemeralIpv6Field]) + }, [canAttachV6, v6Pools, ephemeralIpv6, ephemeralIpv6Field, ephemeralIpv6PoolField]) const noNicMessage = (version: IpVersion) => ( <> From 5600704fe378eb78ccbcbf1badf1407c30b2ae8d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 6 Feb 2026 21:23:05 -0800 Subject: [PATCH 06/11] fix text --- test/e2e/instance-create.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 7e21b58c2..461315c13 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1025,8 +1025,8 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem await expect(ephemeralRows).toHaveCount(2) // Verify one is IPv4 and one is IPv6 - await expect(externalIpsTable.getByText('IPv4')).toBeVisible() - await expect(externalIpsTable.getByText('IPv6')).toBeVisible() + await expect(externalIpsTable.getByText('v4')).toBeVisible() + await expect(externalIpsTable.getByText('v6')).toBeVisible() }) test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) => { From ee8c38a4220de84672a8e5a8b69513b0de1aebbb Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 9 Feb 2026 17:14:07 -0800 Subject: [PATCH 07/11] Use Remeda helpers --- app/forms/instance-create.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index f5db54b70..d7f78f45e 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -817,14 +817,7 @@ const NetworkingSection = ({ () => unicastPools.filter(poolHasIpVersion(compatibleVersions)), [unicastPools, compatibleVersions] ) - const v4Pools = useMemo( - () => compatiblePools.filter((p) => p.ipVersion === 'v4'), - [compatiblePools] - ) - const v6Pools = useMemo( - () => compatiblePools.filter((p) => p.ipVersion === 'v6'), - [compatiblePools] - ) + const [v4Pools, v6Pools] = R.partition(compatiblePools, (p) => p.ipVersion === 'v4') const canAttachV4 = compatibleVersions.includes('v4') && v4Pools.length > 0 const canAttachV6 = compatibleVersions.includes('v6') && v6Pools.length > 0 @@ -848,9 +841,11 @@ const NetworkingSection = ({ .filter(ipHasVersion(compatibleVersions)) }, [attachableFloatingIps, attachedFloatingIps, compatibleVersions]) - const attachedFloatingIpsData = attachedFloatingIps - .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) - .filter((ip) => !!ip) + const attachedFloatingIpsData = R.compact( + attachedFloatingIps.map((floatingIp) => + attachableFloatingIps.find((fip) => fip.name === floatingIp) + ) + ) // Clean up incompatible ephemeral IP selections when NIC changes useEffect(() => { From bcb777990e525ddc029f3ed78fecb7f5d18d90cf Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 9 Feb 2026 17:41:40 -0800 Subject: [PATCH 08/11] revert compact function, as it seems to be unavailable --- app/forms/instance-create.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index d7f78f45e..d3e5b0c02 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -841,11 +841,9 @@ const NetworkingSection = ({ .filter(ipHasVersion(compatibleVersions)) }, [attachableFloatingIps, attachedFloatingIps, compatibleVersions]) - const attachedFloatingIpsData = R.compact( - attachedFloatingIps.map((floatingIp) => - attachableFloatingIps.find((fip) => fip.name === floatingIp) - ) - ) + const attachedFloatingIpsData = attachedFloatingIps + .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) + .filter((ip): ip is FloatingIp => !!ip) // Clean up incompatible ephemeral IP selections when NIC changes useEffect(() => { From 46238693d9055382f78c6b94891d50f1b3635828 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 10 Feb 2026 10:42:11 -0800 Subject: [PATCH 09/11] Refactoring post-aipr review --- app/components/form/fields/IpPoolSelector.tsx | 4 +++- app/forms/instance-create.tsx | 23 +++++++++++-------- test/e2e/instance-create.e2e.ts | 19 +++++++-------- test/e2e/ip-pool-silo-config.e2e.ts | 14 +++++------ 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index f677b1c62..e40b696b2 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -55,6 +55,7 @@ type IpPoolSelectorProps< compatibleVersions?: IpVersion[] required?: boolean hideOptionalTag?: boolean + label?: string } export function IpPoolSelector< @@ -69,6 +70,7 @@ export function IpPoolSelector< compatibleVersions = ALL_IP_VERSIONS, required = true, hideOptionalTag = false, + label = 'Pool', }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component const sortedPools = useMemo(() => { @@ -86,7 +88,7 @@ export function IpPoolSelector< unicastPools.filter(poolHasIpVersion(compatibleVersions)), + // Filter pools by compatibility and partition by IP version + const [v4Pools, v6Pools] = useMemo( + () => + R.partition( + unicastPools.filter(poolHasIpVersion(compatibleVersions)), + (p) => p.ipVersion === 'v4' + ), [unicastPools, compatibleVersions] ) - const [v4Pools, v6Pools] = R.partition(compatiblePools, (p) => p.ipVersion === 'v4') const canAttachV4 = compatibleVersions.includes('v4') && v4Pools.length > 0 const canAttachV6 = compatibleVersions.includes('v6') && v6Pools.length > 0 @@ -845,19 +848,20 @@ const NetworkingSection = ({ .map((floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)) .filter((ip): ip is FloatingIp => !!ip) - // Clean up incompatible ephemeral IP selections when NIC changes + // Clean up incompatible ephemeral IP selections when NIC or pool availability changes useEffect(() => { - // When NIC changes, uncheck and clear incompatible options - if (ephemeralIpv4 && !compatibleVersions.includes('v4')) { + // Uncheck and clear when version incompatible or pools unavailable + if (ephemeralIpv4 && !canAttachV4) { ephemeralIpv4Field.field.onChange(false) ephemeralIpv4PoolField.field.onChange('') } - if (ephemeralIpv6 && !compatibleVersions.includes('v6')) { + if (ephemeralIpv6 && !canAttachV6) { ephemeralIpv6Field.field.onChange(false) ephemeralIpv6PoolField.field.onChange('') } }, [ - compatibleVersions, + canAttachV4, + canAttachV6, ephemeralIpv4, ephemeralIpv4Field, ephemeralIpv4PoolField, @@ -1005,6 +1009,7 @@ const NetworkingSection = ({ disabled={isSubmitting} required={false} hideOptionalTag + label={`${displayVersion} pool`} /> )} diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 461315c13..c38f6b057 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -76,12 +76,12 @@ test('can create an instance', async ({ page }) => { await expect(v6Checkbox).toBeChecked() // IPv4 default pool should be selected - const v4PoolDropdown = page.getByLabel('Pool').first() + const v4PoolDropdown = page.getByLabel('IPv4 pool') await expect(v4PoolDropdown).toBeVisible() await expect(v4PoolDropdown).toContainText('ip-pool-1') // IPv6 default pool should be selected - const v6PoolDropdown = page.getByLabel('Pool').last() + const v6PoolDropdown = page.getByLabel('IPv6 pool') await expect(v6PoolDropdown).toBeVisible() await expect(v6PoolDropdown).toContainText('ip-pool-2') @@ -880,7 +880,7 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' await expect(v6Checkbox).toBeDisabled() // IPv4 pool dropdown should be visible with default selected - const v4PoolDropdown = page.getByLabel('Pool') + const v4PoolDropdown = page.getByLabel('IPv4 pool') await expect(v4PoolDropdown).toBeVisible() await expect(v4PoolDropdown).toContainText('ip-pool-1') @@ -942,7 +942,7 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' await expect(v6Checkbox).toBeChecked() // IPv6 pool dropdown should be visible with default selected - const v6PoolDropdown = page.getByLabel('Pool') + const v6PoolDropdown = page.getByLabel('IPv6 pool') await expect(v6PoolDropdown).toBeVisible() await expect(v6PoolDropdown).toContainText('ip-pool-2') @@ -1003,11 +1003,12 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem await expect(v6Checkbox).toBeChecked() // Both pool dropdowns should be visible with defaults selected - const poolDropdowns = page.getByLabel('Pool') - await expect(poolDropdowns.first()).toBeVisible() - await expect(poolDropdowns.first()).toContainText('ip-pool-1') - await expect(poolDropdowns.last()).toBeVisible() - await expect(poolDropdowns.last()).toContainText('ip-pool-2') + const v4PoolDropdown = page.getByLabel('IPv4 pool') + const v6PoolDropdown = page.getByLabel('IPv6 pool') + await expect(v4PoolDropdown).toBeVisible() + await expect(v4PoolDropdown).toContainText('ip-pool-1') + await expect(v6PoolDropdown).toBeVisible() + await expect(v6PoolDropdown).toContainText('ip-pool-2') // Create the instance await page.getByRole('button', { name: 'Create instance' }).click() diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index f016b33db..544fa6427 100644 --- a/test/e2e/ip-pool-silo-config.e2e.ts +++ b/test/e2e/ip-pool-silo-config.e2e.ts @@ -52,7 +52,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { await expect(v6Checkbox).not.toBeChecked() // IPv4 pool dropdown should be visible with default pool preselected - const v4PoolDropdown = page.getByLabel('Pool') + const v4PoolDropdown = page.getByLabel('IPv4 pool') await expect(v4PoolDropdown).toBeVisible() await expect(v4PoolDropdown).toContainText('ip-pool-1') @@ -90,7 +90,7 @@ test.describe('IP pool configuration: myriad silo (v4-only default)', () => { name: 'Allocate and attach an ephemeral IPv4 address', }) await expect(v4Checkbox).toBeChecked() - await expect(page.getByLabel('Pool')).toContainText('ip-pool-1') + await expect(page.getByLabel('IPv4 pool')).toContainText('ip-pool-1') // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -229,7 +229,7 @@ test.describe('IP pool configuration: thrax silo (v6-only default)', () => { await expect(v6Checkbox).toBeChecked() // IPv6 pool dropdown should be visible with default pool preselected - const v6PoolDropdown = page.getByLabel('Pool') + const v6PoolDropdown = page.getByLabel('IPv6 pool') await expect(v6PoolDropdown).toBeVisible() await expect(v6PoolDropdown).toContainText('ip-pool-2') @@ -279,16 +279,16 @@ test.describe('IP pool configuration: pelerines silo (no defaults)', () => { await expect(v6Checkbox).not.toBeChecked() // Pool dropdowns should not be shown unless ephemeral IPs are enabled - const poolDropdowns = page.getByLabel('Pool') - await expect(poolDropdowns.first()).toBeHidden() + const v4PoolDropdown = page.getByLabel('IPv4 pool') + await expect(v4PoolDropdown).toBeHidden() // Enabling IPv4 ephemeral IP should show pool dropdown await v4Checkbox.click() await expect(v4Checkbox).toBeChecked() - await expect(poolDropdowns.first()).toBeVisible() + await expect(v4PoolDropdown).toBeVisible() // Open dropdown to verify available options - await poolDropdowns.first().click() + await v4PoolDropdown.click() // Both pools are linked to this silo but neither is default await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() }) From 0002e805870408879189cc0bfc7eb2eac94b34f3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 10 Feb 2026 10:53:00 -0800 Subject: [PATCH 10/11] small adjustments --- app/forms/instance-create.tsx | 6 +++++- test/e2e/instance-create.e2e.ts | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index b9c75595c..1f7b10d78 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -995,7 +995,11 @@ const NetworkingSection = ({
}> - + Allocate and attach an ephemeral {displayVersion} address diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index c38f6b057..74bacfd3b 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -153,6 +153,11 @@ test('ephemeral pool selection tracks network interface IP version', async ({ pa await expect(v6Checkbox).toBeEnabled() await expect(v6Checkbox).toBeChecked() + // Verify disabled v4 checkbox shows tooltip + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() + // Change to IPv4-only NIC - v6 checkbox should become disabled and unchecked await selectOption(page, page.getByRole('button', { name: 'IPv6' }), 'IPv4') await expect(v4Checkbox).toBeVisible() @@ -161,6 +166,11 @@ test('ephemeral pool selection tracks network interface IP version', async ({ pa await expect(v6Checkbox).toBeVisible() await expect(v6Checkbox).toBeDisabled() await expect(v6Checkbox).not.toBeChecked() + + // Verify disabled v6 checkbox shows tooltip + await v6Checkbox.hover() + await expect(page.getByText('Add an IPv6 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv6 address')).toBeVisible() }) test('duplicate instance name produces visible error', async ({ page }) => { @@ -1070,6 +1080,16 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) await expect(v6Checkbox).toBeDisabled() await expect(v6Checkbox).not.toBeChecked() + // Verify tooltip shows disabled reason for IPv4 + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() + + // Verify tooltip shows disabled reason for IPv6 + await v6Checkbox.hover() + await expect(page.getByText('Add an IPv6 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv6 address')).toBeVisible() + // Select "Custom" radio → verify ephemeral IP checkboxes are still disabled and unchecked await customRadio.click() await expect(v4Checkbox).toBeVisible() @@ -1079,6 +1099,11 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) await expect(v6Checkbox).toBeDisabled() await expect(v6Checkbox).not.toBeChecked() + // Verify tooltip still shows disabled reason when in Custom mode with no NICs + await v4Checkbox.hover() + await expect(page.getByText('Add an IPv4 network interface')).toBeVisible() + await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() + // Click "Add network interface" button to open modal await page.getByRole('button', { name: 'Add network interface' }).click() From 7641d9f5bb6335fd58ad714f17fd90e35080eeb9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 10 Feb 2026 11:36:52 -0800 Subject: [PATCH 11/11] fix test --- test/e2e/instance-create.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 74bacfd3b..d77a0edcb 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -159,7 +159,7 @@ test('ephemeral pool selection tracks network interface IP version', async ({ pa await expect(page.getByText('to attach an ephemeral IPv4 address')).toBeVisible() // Change to IPv4-only NIC - v6 checkbox should become disabled and unchecked - await selectOption(page, page.getByRole('button', { name: 'IPv6' }), 'IPv4') + await selectOption(page, page.getByRole('button', { name: 'IPv6', exact: true }), 'IPv4') await expect(v4Checkbox).toBeVisible() await expect(v4Checkbox).toBeEnabled() await expect(v4Checkbox).toBeChecked()