diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index f18e42965..e40b696b2 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -54,6 +54,8 @@ type IpPoolSelectorProps< /** Compatible IP versions based on network interface type */ compatibleVersions?: IpVersion[] required?: boolean + hideOptionalTag?: boolean + label?: string } export function IpPoolSelector< @@ -67,6 +69,8 @@ export function IpPoolSelector< disabled = false, 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(() => { @@ -84,12 +88,13 @@ export function IpPoolSelector< ) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 84bfb5bcc..4b1e9b923 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 : '' + + // 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, @@ -328,8 +331,10 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - ephemeralIpPool: defaultEphemeralIpPool || '', - assignEphemeralIp: !!defaultEphemeralIpPool, + ephemeralIpv4: !!defaultV4Pool && defaultCompatibleVersions.includes('v4'), + ephemeralIpv4Pool: defaultV4Pool?.name || '', + ephemeralIpv6: !!defaultV6Pool && defaultCompatibleVersions.includes('v6'), + ephemeralIpv6Pool: defaultV6Pool?.name || '', floatingIps: [], } @@ -438,22 +443,38 @@ 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 + // 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 + ? [ + { + type: 'ephemeral' as const, + poolSelector: values.ephemeralIpv4Pool + ? { type: 'explicit' as const, pool: values.ephemeralIpv4Pool } + : { type: 'auto' as const, ipVersion: 'v4' as const }, + }, + ] + : []), + // v6 ephemeral if enabled + ...(values.ephemeralIpv6 + ? [ + { + type: 'ephemeral' as const, + poolSelector: values.ephemeralIpv6Pool + ? { type: 'explicit' as const, pool: values.ephemeralIpv6Pool } + : { type: 'auto' as const, ipVersion: 'v6' 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 +796,15 @@ 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 ephemeralIpv6 = ephemeralIpv6Field.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 +812,19 @@ const NetworkingSection = ({ [networkInterfaces] ) + // 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 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 } }) @@ -811,88 +846,104 @@ const NetworkingSection = ({ const attachedFloatingIpsData = attachedFloatingIps .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] - ) + .filter((ip): ip is FloatingIp => !!ip) + // Clean up incompatible ephemeral IP selections when NIC or pool availability changes useEffect(() => { - if (!assignEphemeralIp || compatiblePools.length === 0) return + // Uncheck and clear when version incompatible or pools unavailable + if (ephemeralIpv4 && !canAttachV4) { + ephemeralIpv4Field.field.onChange(false) + ephemeralIpv4PoolField.field.onChange('') + } + if (ephemeralIpv6 && !canAttachV6) { + ephemeralIpv6Field.field.onChange(false) + ephemeralIpv6PoolField.field.onChange('') + } + }, [ + canAttachV4, + canAttachV6, + ephemeralIpv4, + ephemeralIpv4Field, + ephemeralIpv4PoolField, + ephemeralIpv6, + ephemeralIpv6Field, + ephemeralIpv6PoolField, + ]) - const currentPoolValid = - ephemeralIpPool && compatiblePools.some((p) => p.name === ephemeralIpPool) - if (currentPoolValid) return + // Track previous canAttach state to detect transitions for auto-enabling + const prevCanAttachV4Ref = useRef(undefined) + const prevCanAttachV6Ref = useRef(undefined) - const defaultPool = compatiblePools.find((p) => p.isDefault) - if (defaultPool) { - ephemeralIpPoolField.field.onChange(defaultPool.name) - } else { - ephemeralIpPoolField.field.onChange('') + // Auto-enable ephemeral IPs when NICs are added that support them + useEffect(() => { + const prevCanAttachV4 = prevCanAttachV4Ref.current + const v4Default = v4Pools.find((p) => p.isDefault) + + // Auto-enable v4 when transitioning from unable to able (e.g., NIC added) + if (canAttachV4 && v4Default && prevCanAttachV4 === false && !ephemeralIpv4) { + ephemeralIpv4Field.field.onChange(true) + // Also populate the pool field with the default + ephemeralIpv4PoolField.field.onChange(v4Default.name) } - }, [assignEphemeralIp, ephemeralIpPool, ephemeralIpPoolField, compatiblePools]) - // Track previous ability to attach ephemeral IP to detect transitions - const prevCanAttachRef = useRef(undefined) + prevCanAttachV4Ref.current = canAttachV4 + }, [canAttachV4, v4Pools, ephemeralIpv4, ephemeralIpv4Field, ephemeralIpv4PoolField]) - // Automatically manage ephemeral IP based on NIC and pool availability 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 prevCanAttachV6 = prevCanAttachV6Ref.current + const v6Default = v6Pools.find((p) => p.isDefault) + + // Auto-enable v6 when transitioning from unable to able (e.g., NIC added) + if (canAttachV6 && v6Default && prevCanAttachV6 === false && !ephemeralIpv6) { + ephemeralIpv6Field.field.onChange(true) + // Also populate the pool field with the default + ephemeralIpv6PoolField.field.onChange(v6Default.name) } - prevCanAttachRef.current = canAttach - }, [assignEphemeralIp, assignEphemeralIpField, compatiblePools, compatibleVersions]) + prevCanAttachV6Ref.current = canAttachV6 + }, [canAttachV6, v6Pools, ephemeralIpv6, ephemeralIpv6Field, ephemeralIpv6PoolField]) - const ephemeralIpCheckboxState = useMemo(() => { - const hasCompatibleNics = compatibleVersions.length > 0 - const hasCompatiblePools = compatiblePools.length > 0 - const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools + 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 + + ) - 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 - - ) + 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 + } - return { canAttachEphemeralIp, disabledReason } - }, [compatibleVersions, compatiblePools]) + // 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 +973,54 @@ 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 an ephemeral {displayVersion} address + + + + {checked && ( +
+ +
+ )} +
+ ) + } + return ( <> {!hasVpcs && ( @@ -947,38 +1046,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..d77a0edcb 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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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('IPv4 pool') + 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('IPv6 pool') + 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,48 @@ 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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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() - await selectOption(page, page.getByRole('button', { name: 'IPv6' }), 'IPv4') - await expect(poolDropdown).toContainText('ip-pool-1') + // 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() + + // 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', exact: true }), 'IPv4') + 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() + + // 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 }) => { @@ -434,9 +458,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 an ephemeral IPv4 address' }) .uncheck() + await page + .getByRole('checkbox', { name: 'Allocate and attach an 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 +875,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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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('IPv4 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 +937,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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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('IPv6 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 +999,45 @@ 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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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 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() - // 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('v4')).toBeVisible() + await expect(externalIpsTable.getByText('v6')).toBeVisible() }) test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) => { @@ -998,8 +1049,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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IPv6 address', }) const defaultRadio = page.getByRole('radio', { name: 'Default', @@ -1008,25 +1062,47 @@ 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() + + // 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(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() + + // 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() @@ -1054,9 +1130,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 +1143,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/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 diff --git a/test/e2e/ip-pool-silo-config.e2e.ts b/test/e2e/ip-pool-silo-config.e2e.ts index 6d68085f7..544fa6427 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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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('IPv4 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,11 +86,11 @@ 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 an ephemeral IPv4 address', }) - await expect(ephemeralCheckbox).toBeChecked() - await expect(page.getByLabel('Pool')).toContainText('ip-pool-1') + await expect(v4Checkbox).toBeChecked() + await expect(page.getByLabel('IPv4 pool')).toContainText('ip-pool-1') // Create instance await page.getByRole('button', { name: 'Create instance' }).click() @@ -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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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('IPv6 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 an ephemeral IPv4 address', + }) + const v6Checkbox = page.getByRole('checkbox', { + name: 'Allocate and attach an 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() - // Enabling ephemeral IP should allow selecting from available pools. - await ephemeralCheckbox.click() - await expect(ephemeralCheckbox).toBeChecked() - await expect(poolDropdown).toBeVisible() + // Pool dropdowns should not be shown unless ephemeral IPs are enabled + 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(v4PoolDropdown).toBeVisible() // Open dropdown to verify available options - await poolDropdown.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() - 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 an 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 an 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')