Skip to content

Update instance-create to handle both v4 and v6 ephemeral IP options#3057

Open
charliepark wants to merge 15 commits intomainfrom
dual_ephemeral_ips
Open

Update instance-create to handle both v4 and v6 ephemeral IP options#3057
charliepark wants to merge 15 commits intomainfrom
dual_ephemeral_ips

Conversation

@charliepark
Copy link
Contributor

@charliepark charliepark commented Feb 7, 2026

Instances can now have both IPv4- and IPv6-backed ephemeral IPs. This updates the instance create flow to account for that.

It checks / unchecks the ephemeral IP box for the IP version(s) specified in the Network Interfaces section of the form.

Default NICs
Screenshot 2026-02-06 at 4 47 32 PM
Screenshot 2026-02-06 at 4 47 49 PM
Screenshot 2026-02-06 at 4 47 56 PM

Custom NICs
Screenshot 2026-02-06 at 4 48 16 PM
Screenshot 2026-02-06 at 4 48 34 PM
Screenshot 2026-02-06 at 4 48 53 PM

No NIC
Screenshot 2026-02-06 at 4 49 01 PM

Closes #3041

@vercel
Copy link

vercel bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
console Ready Ready Preview Feb 14, 2026 2:27am

Request Review

@david-crespo
Copy link
Collaborator

At first I thought maybe we can do without the label, but we do need to indicate somehow what the pool is.

image

What if we put it in the label? Like Allocate IPv6 address from pool:

image

Claude was smart and figured out that the "from pool" should be conditional:

image

The diff is really small, it's like 90% about plumbing through aria-label to make sure we still have a label on the field for screenreaders.

Diff to produce the above ```diff diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index e40b696b22..67893f53f4 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -56,6 +56,7 @@ required?: boolean hideOptionalTag?: boolean label?: string + 'aria-label'?: string }

export function IpPoolSelector<
@@ -71,6 +72,7 @@
required = true,
hideOptionalTag = false,
label = 'Pool',

  • 'aria-label': ariaLabel,
    }: IpPoolSelectorProps<TFieldValues, TName>) {
    // Note: pools are already filtered by poolType before being passed to this component
    const sortedPools = useMemo(() => {
    @@ -89,6 +91,7 @@
    name={poolFieldName}
    items={sortedPools.map(toIpPoolItem)}
    label={label}
  •    aria-label={ariaLabel}
       noItemsPlaceholder="No pools available"
       control={control}
       placeholder="Select a pool"
    

diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx
index 445c8e810c..d93b6488f9 100644
--- a/app/components/form/fields/ListboxField.tsx
+++ b/app/components/form/fields/ListboxField.tsx
@@ -26,6 +26,8 @@
placeholder?: string
className?: string
label?: string

  • /** Accessible label for the button when no visible label is rendered */
  • 'aria-label'?: string
    required?: boolean
    description?: string | React.ReactNode
    control: Control
    @@ -54,6 +56,7 @@
    isLoading,
    noItemsPlaceholder,
    hideOptionalTag,
  • 'aria-label': ariaLabel,
    }: ListboxFieldProps<TFieldValues, TName>) {
    // TODO: recreate this logic
    // validate: (v) => (required && !v ? ${name} is required : undefined),
    @@ -63,6 +66,7 @@
    <Listbox
    description={description}
    label={label}
  •    aria-label={ariaLabel}
       required={required}
       placeholder={placeholder}
       noItemsPlaceholder={noItemsPlaceholder}
    

diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx
index 4b1e9b923c..c2b2f72701 100644
--- a/app/forms/instance-create.tsx
+++ b/app/forms/instance-create.tsx
@@ -1000,12 +1000,13 @@
name={checkboxName}
disabled={!canAttach || isSubmitting}
>

  •          Allocate and attach an ephemeral {displayVersion} address
    
  •          Allocate {displayVersion} address
    
  •          {checked && ' from pool:'}
           </CheckboxField>
         </span>
       </Wrap>
       {checked && (
    
  •      <div className="ml-6">
    
  •      <div className="my-2 ml-6">
           <IpPoolSelector
             control={control}
             poolFieldName={poolFieldName}
    

@@ -1013,7 +1014,8 @@
disabled={isSubmitting}
required={false}
hideOptionalTag

  •          label={`${displayVersion} pool`}
    
  •          label=""
    
  •          aria-label={`${displayVersion} pool`}
           />
         </div>
       )}
    

diff --git a/app/ui/lib/Listbox.tsx b/app/ui/lib/Listbox.tsx
index 0691124bf6..8bf631ff67 100644
--- a/app/ui/lib/Listbox.tsx
+++ b/app/ui/lib/Listbox.tsx
@@ -37,6 +37,8 @@
hasError?: boolean
name?: string
label?: React.ReactNode

  • /** Accessible label for the button when no visible label is rendered */
  • 'aria-label'?: string
    description?: React.ReactNode
    required?: boolean
    isLoading?: boolean
    @@ -63,6 +65,7 @@
    buttonRef,
    hideOptionalTag,
    hideSelected = false,
  • 'aria-label': ariaLabel,
    ...props
    }: ListboxProps) => {
    const selectedItem = selected && items.find((i) => i.value === selected)
    @@ -114,6 +117,7 @@
    hideSelected ? 'w-auto' : 'w-full'
    )}
    ref={buttonRef}
  •          aria-label={ariaLabel}
             {...props}
           >
             {!hideSelected && (
    

</details>

type: 'ephemeral' as const,
poolSelector: values.ephemeralIpv4Pool
? { type: 'explicit' as const, pool: values.ephemeralIpv4Pool }
: { type: 'auto' as const, ipVersion: 'v4' as const },
Copy link
Collaborator

@david-crespo david-crespo Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need the auto case in these because we always have a pool — see the comment in the deleted code

() => unicastPools.filter(poolHasIpVersion(compatibleVersions)),
[unicastPools, compatibleVersions]
)
.filter((ip): ip is FloatingIp => !!ip)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type guard shouldn't be necessary as far as I know — is it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flow to end up in a state where it's actually useful is pretty rare …

  1. User selects "ip-1" from modal → added to attachedFloatingIps (form state)
  2. React Query refetches floatingIpList (on window focus, reconnect, or stale time expiry)
  3. "ip-1" is now attached to another instance or deleted (via some other user on the system)
  4. "ip-1" filtered out of attachableFloatingIps
  5. Form state still has "ip-1", but .find() can't find it

But TS complains* unless it has either a filter (.filter((ip) => ip !== undefined) or .filter((ip) => !!ip)), or an ! assertion (floatingIp) => attachableFloatingIps.find((fip) => fip.name === floatingIp)!

  • The complaint is in the MiniTable, where an item might be undefined.

I'm moving to an assertion as that should throw an error if the user ends up in that state, versus having the Floating IP silently disappear, but I'm open to filtering if that feels like a better situation.

</div>
)
}

Copy link
Collaborator

@david-crespo david-crespo Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't define components in render. Probably worth putting in the CLAUDE.md. There's an eslint rule for this, react/no-unstable-nested-components, but oxlint doesn't have it yet.

<br />
for this instance’s network interfaces
</>
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These and getDisabledReason should be defined at top level probably. They're pure functions.

poolFieldName={poolFieldName}
pools={pools}
disabled={isSubmitting}
required={false}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the previous version this wasn't conditionally rendered — I used a class to hide it while keeping it mounted so that I could have the required prop change its value on the form, enforcing required when the box was checked but not when it was unchecked. Hard coding required: false avoids that problem because the requirement never changes, but it only works if we can guarantee that there is always a pool selected when we expect there to be.

const defaultV4Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v4')
const defaultV6Pool = compatibleDefaultPools.find((p) => p.ipVersion === 'v6')
...

    ephemeralIpv4: !!defaultV4Pool && defaultCompatibleVersions.includes('v4'),
    ephemeralIpv4Pool: defaultV4Pool?.name || '',
    ephemeralIpv6: !!defaultV6Pool && defaultCompatibleVersions.includes('v6'),
    ephemeralIpv6Pool: defaultV6Pool?.name || '',

This logic means that if there is a v6 pool but there is no default v6 pool, then it's possible to check the IPv6 ephemeral IP box but not select a pool. I was able to confirm this by hand with the pelerines silo, which has a v6 pool but no default. It lets me check the box and not pick a pool, and then I get an error from the API.

But if instead you always render IpPoolSelector, hide it when the checkbox is unchecked, and have required={checked}, you solve this problem with form validation — it's a form error to not have a pool picked. In hindsight I should have left a comment on required={assignEphemeralIp} and the hidden class thing explaining what it was for.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Instance create: allow creating ephemeral IPs of both versions

2 participants