From e685eb49bc77ce656cb97701d8b16e8f13e9875a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 8 Jan 2026 16:00:47 -0800 Subject: [PATCH 01/73] Update to new networking API shape, with IPv6 --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 253 +++++++++++++++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 202 ++++++++++++-- app/components/AttachEphemeralIpModal.tsx | 7 +- app/forms/floating-ip-create.tsx | 22 +- app/forms/instance-create.tsx | 6 +- app/forms/network-interface-create.tsx | 26 +- app/forms/network-interface-edit.tsx | 18 +- app/pages/project/instances/NetworkingTab.tsx | 34 ++- app/util/ip-stack.ts | 31 +++ mock-api/msw/db.ts | 7 + mock-api/msw/handlers.ts | 118 ++++++-- mock-api/network-interface.ts | 9 +- test/e2e/instance-networking.e2e.ts | 2 +- 15 files changed, 636 insertions(+), 103 deletions(-) create mode 100644 app/util/ip-stack.ts diff --git a/OMICRON_VERSION b/OMICRON_VERSION index cd93756d9..2a5ae4903 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -dd74446cbe12d52540d92b62f2de7eaf6520d591 +135be59591f1ba4bc5941f63bf3a08a0b187b1a9 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index d3abd0aec..db95aa333 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -170,6 +170,49 @@ export type AddressLotViewResponse = { lot: AddressLot } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP pool to allocate from. + */ +export type PoolSelector = + /** Use the specified pool by name or ID. */ + | { + /** The pool to allocate from. */ + pool: NameOrId + type: 'explicit' + } + /** Use the default pool for the silo. */ + | { + /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ + ipVersion?: IpVersion | null + type: 'auto' + } + +/** + * Specify how to allocate a floating IP address. + */ +export type AddressSelector = + /** Reserve a specific IP address. */ + | { + /** The IP address to reserve. Must be available in the pool. */ + ip: string + /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ + pool?: NameOrId | null + type: 'explicit' + } + /** Automatically allocate an IP address from a specified pool. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * Describes the scope of affinity for the purposes of co-location. */ @@ -1934,8 +1977,8 @@ export type Distributionint64 = { * Parameters for creating an ephemeral IP address for an instance. */ export type EphemeralIpCreate = { - /** Name or ID of the IP pool used to allocate an address. If unspecified, the default IP pool will be used. */ - pool?: NameOrId | null + /** Pool to allocate from. */ + poolSelector?: PoolSelector } export type ExternalIp = @@ -1981,8 +2024,12 @@ SNAT addresses are ephemeral addresses used only for outbound connectivity. */ * Parameters for creating an external IP address for instances. */ export type ExternalIpCreate = - /** An IP address providing both inbound and outbound access. The address is automatically assigned from the provided IP pool or the default IP pool if not specified. */ - | { pool?: NameOrId | null; type: 'ephemeral' } + /** An IP address providing both inbound and outbound access. The address is automatically assigned from a pool. */ + | { + /** Pool to allocate from. */ + poolSelector?: PoolSelector + type: 'ephemeral' + } /** An IP address providing both inbound and outbound access. The address is an existing floating IP object assigned to the current project. The floating IP must not be in use by another instance or service. */ @@ -2126,12 +2173,10 @@ export type FloatingIpAttach = { * Parameters for creating a new floating IP address for instances. */ export type FloatingIpCreate = { + /** IP address allocation method. */ + addressSelector?: AddressSelector description: string - /** An IP address to reserve for use as a floating IP. This field is optional: when not set, an address will be automatically chosen from `pool`. If set, then the IP must be available in the resolved `pool`. */ - ip?: string | null name: Name - /** The parent IP pool that a floating IP is pulled from. If unset, the default pool is selected. */ - pool?: NameOrId | null } /** @@ -2382,18 +2427,70 @@ export type InstanceDiskAttachment = type: 'attach' } +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export type Ipv4Assignment = + /** Automatically assign an IP address from the VPC Subnet. */ + | { type: 'auto' } + /** Explicitly assign a specific address, if available. */ + | { type: 'explicit'; value: string } + +/** + * Configuration for a network interface's IPv4 addressing. + */ +export type PrivateIpv4StackCreate = { + /** The VPC-private address to assign to the interface. */ + ip: Ipv4Assignment + /** Additional IP networks the interface can send / receive on. */ + transitIps?: Ipv4Net[] +} + +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export type Ipv6Assignment = + /** Automatically assign an IP address from the VPC Subnet. */ + | { type: 'auto' } + /** Explicitly assign a specific address, if available. */ + | { type: 'explicit'; value: string } + +/** + * Configuration for a network interface's IPv6 addressing. + */ +export type PrivateIpv6StackCreate = { + /** The VPC-private address to assign to the interface. */ + ip: Ipv6Assignment + /** Additional IP networks the interface can send / receive on. */ + transitIps?: Ipv6Net[] +} + +/** + * Create parameters for a network interface's IP stack. + */ +export type PrivateIpStackCreate = + /** The interface has only an IPv4 stack. */ + | { type: 'v4'; value: PrivateIpv4StackCreate } + /** The interface has only an IPv6 stack. */ + | { type: 'v6'; value: PrivateIpv6StackCreate } + /** The interface has both an IPv4 and IPv6 stack. */ + | { + type: 'dual_stack' + value: { v4: PrivateIpv4StackCreate; v6: PrivateIpv6StackCreate } + } + /** * Create-time parameters for an `InstanceNetworkInterface` */ export type InstanceNetworkInterfaceCreate = { description: string - /** The IP address for the interface. One will be auto-assigned if not provided. */ - ip?: string | null + /** The IP stack configuration for this interface. + +If not provided, a default configuration will be used, which creates a dual-stack IPv4 / IPv6 interface. */ + ipConfig?: PrivateIpStackCreate name: Name /** The VPC Subnet in which to create the interface. */ subnetName: Name - /** A set of additional networks that this interface may send and receive traffic on. */ - transitIps?: IpNet[] /** The VPC in which to create the interface. */ vpcName: Name } @@ -2406,8 +2503,18 @@ export type InstanceNetworkInterfaceAttachment = If more than one interface is provided, then the first will be designated the primary interface for the instance. */ | { params: InstanceNetworkInterfaceCreate[]; type: 'create' } - /** The default networking configuration for an instance is to create a single primary interface with an automatically-assigned IP address. The IP will be pulled from the Project's default VPC / VPC Subnet. */ - | { type: 'default' } + /** Create a single primary interface with an automatically-assigned IPv4 address. + +The IP will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_ipv4' } + /** Create a single primary interface with an automatically-assigned IPv6 address. + +The IP will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_ipv6' } + /** Create a single primary interface with automatically-assigned IPv4 and IPv6 addresses. + +The IPs will be pulled from the Project's default VPC / VPC Subnet. */ + | { type: 'default_dual_stack' } /** No network interfaces at all will be created for the instance. */ | { type: 'none' } @@ -2467,6 +2574,37 @@ If not provided, all SSH public keys from the user's profile will be sent. If an userData?: string } +/** + * The VPC-private IPv4 stack for a network interface + */ +export type PrivateIpv4Stack = { + /** The VPC-private IPv4 address for the interface. */ + ip: string + /** A set of additional IPv4 networks that this interface may send and receive traffic on. */ + transitIps: Ipv4Net[] +} + +/** + * The VPC-private IPv6 stack for a network interface + */ +export type PrivateIpv6Stack = { + /** The VPC-private IPv6 address for the interface. */ + ip: string + /** A set of additional IPv6 networks that this interface may send and receive traffic on. */ + transitIps: Ipv6Net[] +} + +/** + * The VPC-private IP stack for a network interface. + */ +export type PrivateIpStack = + /** The interface has only an IPv4 stack. */ + | { type: 'v4'; value: PrivateIpv4Stack } + /** The interface has only an IPv6 stack. */ + | { type: 'v6'; value: PrivateIpv6Stack } + /** The interface is dual-stack IPv4 and IPv6. */ + | { type: 'dual_stack'; value: { v4: PrivateIpv4Stack; v6: PrivateIpv6Stack } } + /** * A MAC address * @@ -2484,8 +2622,8 @@ export type InstanceNetworkInterface = { id: string /** The Instance to which the interface belongs. */ instanceId: string - /** The IP address assigned to this interface. */ - ip: string + /** The VPC-private IP stack for this interface. */ + ipStack: PrivateIpStack /** The MAC address assigned to this interface. */ mac: MacAddr /** unique, mutable, user-controlled identifier for each resource */ @@ -2498,8 +2636,6 @@ export type InstanceNetworkInterface = { timeCreated: Date /** timestamp when this resource was last modified */ timeModified: Date - /** A set of additional networks that this interface may send and receive traffic on. */ - transitIps?: IpNet[] /** The VPC to which the interface belongs. */ vpcId: string } @@ -2528,7 +2664,7 @@ If applied to a secondary interface, that interface will become the primary on t Note that this can only be used to select a new primary interface for an instance. Requests to change the primary interface into a secondary will return an error. */ primary?: boolean - /** A set of additional networks that this interface may send and receive traffic on. */ + /** A set of additional networks that this interface may send and receive traffic on */ transitIps?: IpNet[] } @@ -2698,11 +2834,6 @@ export type InternetGatewayResultsPage = { nextPage?: string | null } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - /** * Type of IP pool. */ @@ -2727,7 +2858,7 @@ export type IpPool = { ipVersion: IpVersion /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** Type of IP pool (unicast or multicast) */ + /** Type of IP pool (unicast or multicast). */ poolType: IpPoolType /** timestamp when this resource was created */ timeCreated: Date @@ -2754,7 +2885,9 @@ The default is IPv4. */ } export type IpPoolLinkSilo = { - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean silo: NameOrId } @@ -2807,7 +2940,9 @@ export type IpPoolResultsPage = { */ export type IpPoolSiloLink = { ipPoolId: string - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean siloId: string } @@ -2823,7 +2958,9 @@ export type IpPoolSiloLinkResultsPage = { } export type IpPoolSiloUpdate = { - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo, so when a pool is made default, an existing default will remain linked but will no longer be the default. */ + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. When a pool is made default, an existing default of the same type and version will remain linked but will no longer be the default. */ isDefault: boolean } @@ -3194,6 +3331,49 @@ export type MulticastGroupUpdate = { sourceIps?: string[] | null } +/** + * VPC-private IPv4 configuration for a network interface. + */ +export type PrivateIpv4Config = { + /** VPC-private IP address. */ + ip: string + /** The IP subnet. */ + subnet: Ipv4Net + /** Additional networks on which the interface can send / receive traffic. */ + transitIps?: Ipv4Net[] +} + +/** + * VPC-private IPv6 configuration for a network interface. + */ +export type PrivateIpv6Config = { + /** VPC-private IP address. */ + ip: string + /** The IP subnet. */ + subnet: Ipv6Net + /** Additional networks on which the interface can send / receive traffic. */ + transitIps: Ipv6Net[] +} + +/** + * VPC-private IP address configuration for a network interface. + */ +export type PrivateIpConfig = + /** The interface has only an IPv4 configuration. */ + | { type: 'v4'; value: PrivateIpv4Config } + /** The interface has only an IPv6 configuration. */ + | { type: 'v6'; value: PrivateIpv6Config } + /** The interface is dual-stack. */ + | { + type: 'dual_stack' + value: { + /** The interface's IPv4 configuration. */ + v4: PrivateIpv4Config + /** The interface's IPv6 configuration. */ + v6: PrivateIpv6Config + } + } + /** * The type of network interface */ @@ -3215,14 +3395,12 @@ export type Vni = number */ export type NetworkInterface = { id: string - ip: string + ipConfig: PrivateIpConfig kind: NetworkInterfaceKind mac: MacAddr name: Name primary: boolean slot: number - subnet: IpNet - transitIps?: IpNet[] vni: Vni } @@ -3381,8 +3559,9 @@ export type Probe = { */ export type ProbeCreate = { description: string - ipPool?: NameOrId | null name: Name + /** Pool to allocate from. */ + poolSelector?: PoolSelector sled: string } @@ -3820,10 +3999,16 @@ export type SiloIpPool = { description: string /** unique, immutable, system-controlled identifier for each resource */ id: string - /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. There can be at most one default for a given silo. */ + /** The IP version for the pool. */ + ipVersion: IpVersion + /** When a pool is the default for a silo, floating IPs and instance ephemeral IPs will come from that pool when no other pool is specified. + +A silo can have at most one default pool per combination of pool type (unicast or multicast) and IP version (IPv4 or IPv6), allowing up to 4 default pools total. */ isDefault: boolean /** unique, mutable, user-controlled identifier for each resource */ name: Name + /** Type of IP pool (unicast or multicast). */ + poolType: IpPoolType /** timestamp when this resource was created */ timeCreated: Date /** timestamp when this resource was last modified */ @@ -6865,7 +7050,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2025121200.0.0' + apiVersion = '2026010500.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index ee1f8feeb..cc761a204 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -dd74446cbe12d52540d92b62f2de7eaf6520d591 +135be59591f1ba4bc5941f63bf3a08a0b187b1a9 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 6b776098e..579588256 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -174,6 +174,43 @@ export const AddressLotViewResponse = z.preprocess( z.object({ blocks: AddressLotBlock.array(), lot: AddressLot }) ) +/** + * The IP address version. + */ +export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) + +/** + * Specify which IP pool to allocate from. + */ +export const PoolSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ pool: NameOrId, type: z.enum(['explicit']) }), + z.object({ + ipVersion: IpVersion.nullable().default(null).optional(), + type: z.enum(['auto']), + }), + ]) +) + +/** + * Specify how to allocate a floating IP address. + */ +export const AddressSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ + ip: z.ipv4(), + pool: NameOrId.nullable().optional(), + type: z.enum(['explicit']), + }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + type: z.enum(['auto']), + }), + ]) +) + /** * Describes the scope of affinity for the purposes of co-location. */ @@ -1778,7 +1815,9 @@ export const Distributionint64 = z.preprocess( */ export const EphemeralIpCreate = z.preprocess( processResponseBody, - z.object({ pool: NameOrId.nullable().optional() }) + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + }) ) /** @@ -1821,7 +1860,10 @@ export const ExternalIp = z.preprocess( export const ExternalIpCreate = z.preprocess( processResponseBody, z.union([ - z.object({ pool: NameOrId.nullable().optional(), type: z.enum(['ephemeral']) }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + type: z.enum(['ephemeral']), + }), z.object({ floatingIp: NameOrId, type: z.enum(['floating']) }), ]) ) @@ -1972,10 +2014,12 @@ export const FloatingIpAttach = z.preprocess( export const FloatingIpCreate = z.preprocess( processResponseBody, z.object({ + addressSelector: AddressSelector.default({ + poolSelector: { ipVersion: null, type: 'auto' }, + type: 'auto', + }).optional(), description: z.string(), - ip: z.ipv4().nullable().optional(), name: Name, - pool: NameOrId.nullable().optional(), }) ) @@ -2212,6 +2256,59 @@ export const InstanceDiskAttachment = z.preprocess( ]) ) +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export const Ipv4Assignment = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['auto']) }), + z.object({ type: z.enum(['explicit']), value: z.ipv4() }), + ]) +) + +/** + * Configuration for a network interface's IPv4 addressing. + */ +export const PrivateIpv4StackCreate = z.preprocess( + processResponseBody, + z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]).optional() }) +) + +/** + * How a VPC-private IP address is assigned to a network interface. + */ +export const Ipv6Assignment = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['auto']) }), + z.object({ type: z.enum(['explicit']), value: z.ipv6() }), + ]) +) + +/** + * Configuration for a network interface's IPv6 addressing. + */ +export const PrivateIpv6StackCreate = z.preprocess( + processResponseBody, + z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]).optional() }) +) + +/** + * Create parameters for a network interface's IP stack. + */ +export const PrivateIpStackCreate = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4StackCreate }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6StackCreate }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4StackCreate, v6: PrivateIpv6StackCreate }), + }), + ]) +) + /** * Create-time parameters for an `InstanceNetworkInterface` */ @@ -2219,10 +2316,15 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ip: z.ipv4().nullable().optional(), + ipConfig: PrivateIpStackCreate.default({ + type: 'dual_stack', + value: { + v4: { ip: { type: 'auto' }, transitIps: [] }, + v6: { ip: { type: 'auto' }, transitIps: [] }, + }, + }).optional(), name: Name, subnetName: Name, - transitIps: IpNet.array().default([]).optional(), vpcName: Name, }) ) @@ -2234,7 +2336,9 @@ export const InstanceNetworkInterfaceAttachment = z.preprocess( processResponseBody, z.union([ z.object({ params: InstanceNetworkInterfaceCreate.array(), type: z.enum(['create']) }), - z.object({ type: z.enum(['default']) }), + z.object({ type: z.enum(['default_ipv4']) }), + z.object({ type: z.enum(['default_ipv6']) }), + z.object({ type: z.enum(['default_dual_stack']) }), z.object({ type: z.enum(['none']) }), ]) ) @@ -2258,7 +2362,7 @@ export const InstanceCreate = z.preprocess( name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ - type: 'default', + type: 'default_dual_stack', }).optional(), sshPublicKeys: NameOrId.array().nullable().optional(), start: SafeBoolean.default(true).optional(), @@ -2266,6 +2370,37 @@ export const InstanceCreate = z.preprocess( }) ) +/** + * The VPC-private IPv4 stack for a network interface + */ +export const PrivateIpv4Stack = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv4(), transitIps: Ipv4Net.array() }) +) + +/** + * The VPC-private IPv6 stack for a network interface + */ +export const PrivateIpv6Stack = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv6(), transitIps: Ipv6Net.array() }) +) + +/** + * The VPC-private IP stack for a network interface. + */ +export const PrivateIpStack = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4Stack }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6Stack }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4Stack, v6: PrivateIpv6Stack }), + }), + ]) +) + /** * A MAC address * @@ -2289,14 +2424,13 @@ export const InstanceNetworkInterface = z.preprocess( description: z.string(), id: z.uuid(), instanceId: z.uuid(), - ip: z.ipv4(), + ipStack: PrivateIpStack, mac: MacAddr, name: Name, primary: SafeBoolean, subnetId: z.uuid(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), - transitIps: IpNet.array().default([]).optional(), vpcId: z.uuid(), }) ) @@ -2468,11 +2602,6 @@ export const InternetGatewayResultsPage = z.preprocess( z.object({ items: InternetGateway.array(), nextPage: z.string().nullable().optional() }) ) -/** - * The IP address version. - */ -export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) - /** * Type of IP pool. */ @@ -2904,6 +3033,41 @@ export const MulticastGroupUpdate = z.preprocess( }) ) +/** + * VPC-private IPv4 configuration for a network interface. + */ +export const PrivateIpv4Config = z.preprocess( + processResponseBody, + z.object({ + ip: z.ipv4(), + subnet: Ipv4Net, + transitIps: Ipv4Net.array().default([]).optional(), + }) +) + +/** + * VPC-private IPv6 configuration for a network interface. + */ +export const PrivateIpv6Config = z.preprocess( + processResponseBody, + z.object({ ip: z.ipv6(), subnet: Ipv6Net, transitIps: Ipv6Net.array() }) +) + +/** + * VPC-private IP address configuration for a network interface. + */ +export const PrivateIpConfig = z.preprocess( + processResponseBody, + z.union([ + z.object({ type: z.enum(['v4']), value: PrivateIpv4Config }), + z.object({ type: z.enum(['v6']), value: PrivateIpv6Config }), + z.object({ + type: z.enum(['dual_stack']), + value: z.object({ v4: PrivateIpv4Config, v6: PrivateIpv6Config }), + }), + ]) +) + /** * The type of network interface */ @@ -2928,14 +3092,12 @@ export const NetworkInterface = z.preprocess( processResponseBody, z.object({ id: z.uuid(), - ip: z.ipv4(), + ipConfig: PrivateIpConfig, kind: NetworkInterfaceKind, mac: MacAddr, name: Name, primary: SafeBoolean, slot: z.number().min(0).max(255), - subnet: IpNet, - transitIps: IpNet.array().default([]).optional(), vni: Vni, }) ) @@ -3097,8 +3259,8 @@ export const ProbeCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ipPool: NameOrId.nullable().optional(), name: Name, + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), sled: z.uuid(), }) ) @@ -3498,8 +3660,10 @@ export const SiloIpPool = z.preprocess( z.object({ description: z.string(), id: z.uuid(), + ipVersion: IpVersion, isDefault: SafeBoolean, name: Name, + poolType: IpPoolType, timeCreated: z.coerce.date(), timeModified: z.coerce.date(), }) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index af8a384c1..24f8aec03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,13 +65,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) + onAction={() => { + if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { pool }, + body: { poolSelector: { type: 'explicit', pool } }, }) - } + }} onDismiss={onDismiss} > diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5ff9086f7..4ff110568 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,10 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues: Omit = { +const defaultValues: FloatingIpCreate = { name: '', description: '', - pool: undefined, + addressSelector: undefined, } export const handle = titleCrumb('New Floating IP') @@ -65,7 +65,21 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(body) => createFloatingIp.mutate({ query: projectSelector, body })} + onSubmit={(values) => { + // Transform the form values to properly construct addressSelector + const pool = form.getValues('addressSelector.poolSelector.pool' as any) + const body = { + name: values.name, + description: values.description, + addressSelector: pool + ? { + type: 'auto' as const, + poolSelector: { type: 'explicit' as const, pool }, + } + : undefined, + } + createFloatingIp.mutate({ query: projectSelector, body }) + }} loading={createFloatingIp.isPending} submitError={createFloatingIp.error} > @@ -89,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() { /> , 'ip'> = { +const defaultValues = { name: '', description: '', - ip: '', subnetName: '', vpcName: '', + ip: '', } type CreateNetworkInterfaceFormProps = { @@ -60,7 +59,19 @@ export function CreateNetworkInterfaceForm({ resourceName="network interface" title="Add network interface" onDismiss={onDismiss} - onSubmit={({ ip, ...rest }) => onSubmit({ ip: ip.trim() || undefined, ...rest })} + onSubmit={({ ip, ...rest }) => { + // Transform to IPv4 ipConfig structure + const ipConfig = ip.trim() + ? { + type: 'v4' as const, + value: { + ip: { type: 'explicit' as const, value: ip.trim() }, + transitIps: [], + }, + } + : undefined + onSubmit({ ...rest, ipConfig }) + }} loading={loading} submitError={submitError} > @@ -83,7 +94,12 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - + ) } diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cc7d2a482..431815a70 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -7,7 +7,7 @@ */ import { useEffect } from 'react' import { useForm } from 'react-hook-form' -import * as R from 'remeda' + import { api, @@ -52,11 +52,17 @@ export function EditNetworkInterfaceForm({ }, }) - const defaultValues = R.pick(editing, [ - 'name', - 'description', - 'transitIps', - ]) satisfies InstanceNetworkInterfaceUpdate + // Extract transitIps from ipStack for the form + const extractedTransitIps = + editing.ipStack.type === 'dual_stack' + ? editing.ipStack.value.v4.transitIps + : editing.ipStack.value.transitIps + + const defaultValues = { + name: editing.name, + description: editing.description, + transitIps: extractedTransitIps, + } satisfies InstanceNetworkInterfaceUpdate const form = useForm({ defaultValues }) const transitIps = form.watch('transitIps') || [] diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index e968ea31f..6f0395121 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -152,9 +152,15 @@ const staticCols = [ ), }), colHelper.accessor('description', Columns.description), - colHelper.accessor('ip', { + colHelper.display({ + id: 'ip', header: 'Private IP', - cell: (info) => , + cell: (info) => { + const nic = info.row.original + const ip = + nic.ipStack.type === 'dual_stack' ? nic.ipStack.value.v4.ip : nic.ipStack.value.ip + return + }, }), colHelper.accessor('vpcId', { header: 'vpc', @@ -164,15 +170,23 @@ const staticCols = [ header: 'subnet', cell: (info) => , }), - colHelper.accessor('transitIps', { + colHelper.display({ + id: 'transitIps', header: 'Transit IPs', - cell: (info) => ( - - {info.getValue()?.map((ip) => ( -
{ip}
- ))} -
- ), + cell: (info) => { + const nic = info.row.original + const transitIps = + nic.ipStack.type === 'dual_stack' + ? nic.ipStack.value.v4.transitIps + : nic.ipStack.value.transitIps + return ( + + {transitIps?.map((ip) => ( +
{ip}
+ ))} +
+ ) + }, }), ] diff --git a/app/util/ip-stack.ts b/app/util/ip-stack.ts new file mode 100644 index 000000000..d26461112 --- /dev/null +++ b/app/util/ip-stack.ts @@ -0,0 +1,31 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { InstanceNetworkInterface } from '@oxide/api' + +/** + * Extract the primary IP address from an instance network interface's IP stack. + * For dual-stack, returns the IPv4 address. + */ +export function getIpFromStack(nic: InstanceNetworkInterface): string { + if (nic.ipStack.type === 'dual_stack') { + return nic.ipStack.value.v4.ip + } + return nic.ipStack.value.ip +} + +/** + * Extract transit IPs from an instance network interface's IP stack. + * For dual-stack, returns the IPv4 transit IPs (since transit IPs are generally version-agnostic in the UI). + */ +export function getTransitIpsFromStack(nic: InstanceNetworkInterface): string[] { + if (nic.ipStack.type === 'dual_stack') { + return nic.ipStack.value.v4.transitIps + } + return nic.ipStack.value.transitIps +} diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 95ce6718d..7262cbb36 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -62,6 +62,13 @@ function ensureNoParentSelectors( export const resolveIpPool = (pool: string | undefined | null) => pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) +export const resolvePoolSelector = ( + poolSelector: { pool: string; type: 'explicit' } | { type: 'auto' } | undefined +) => + poolSelector?.type === 'explicit' + ? lookup.ipPool({ pool: poolSelector.pool }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) if (!ipPoolRange) throw notFoundErr(`IP range for pool '${pool.name}'`) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 3f678f5c2..42f2f9af0 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -42,7 +42,8 @@ import { lookup, lookupById, notFoundErr, - resolveIpPool, + + resolvePoolSelector, utilizationForSilo, } from './db' import { @@ -72,6 +73,56 @@ import { // client camel-cases the keys and parses date fields. Inside the mock API everything // is *JSON type. +// Helper to resolve IP assignment to actual IP string +const resolveIp = ( + assignment: { type: 'auto' } | { type: 'explicit'; value: string }, + defaultIp = '127.0.0.1' +) => (assignment.type === 'explicit' ? assignment.value : defaultIp) + +// Convert PrivateIpStackCreate to PrivateIpStack +const resolveIpStack = ( + config: + | { type: 'v4'; value: Api.PrivateIpv4StackCreate } + | { type: 'v6'; value: Api.PrivateIpv6StackCreate } + | { + type: 'dual_stack' + value: { v4: Api.PrivateIpv4StackCreate; v6: Api.PrivateIpv6StackCreate } + }, + defaultIp = '127.0.0.1' +): + | { type: 'v4'; value: { ip: string; transit_ips: string[] } } + | { type: 'v6'; value: { ip: string; transit_ips: string[] } } + | { + type: 'dual_stack' + value: { + v4: { ip: string; transit_ips: string[] } + v6: { ip: string; transit_ips: string[] } + } + } => { + if (config.type === 'dual_stack') { + return { + type: 'dual_stack', + value: { + v4: { + ip: resolveIp(config.value.v4.ip, defaultIp), + transit_ips: config.value.v4.transitIps || [], + }, + v6: { + ip: resolveIp(config.value.v6.ip, defaultIp), + transit_ips: config.value.v6.transitIps || [], + }, + }, + } + } + return { + type: config.type, + value: { + ip: resolveIp(config.value.ip, defaultIp), + transit_ips: config.value.transitIps || [], + }, + } +} + export const handlers = makeHandlers({ logout: () => 204, ping: () => ({ status: 'ok' }), @@ -254,16 +305,23 @@ export const handlers = makeHandlers({ errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool - const pool = body.pool - ? lookup.siloIpPool({ pool: body.pool, silo: defaultSilo.id }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + const addressSelector = body.address_selector || { type: 'auto' } + const pool = + addressSelector.type === 'explicit' && addressSelector.pool + ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) + : addressSelector.type === 'auto' && addressSelector.pool_selector?.type === 'explicit' + ? lookup.siloIpPool({ + pool: addressSelector.pool_selector.pool, + silo: defaultSilo.id, + }) + : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) const newFloatingIp: Json = { id: uuid(), project_id: project.id, // TODO: use ip-num to actually get the next available IP in the pool ip: - body.ip || + (addressSelector.type === 'explicit' && addressSelector.ip) || Array.from({ length: 4 }) .map(() => Math.floor(Math.random() * 256)) .join('.'), @@ -473,7 +531,7 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - const pool = resolveIpPool(ip.pool) + const pool = resolvePoolSelector(ip.pool_selector) getIpFromPool(pool) } }) @@ -517,14 +575,30 @@ export const handlers = makeHandlers({ // a hack but not very important const anyVpc = db.vpcs.find((v) => v.project_id === project.id) const anySubnet = db.vpcSubnets.find((s) => s.vpc_id === anyVpc?.id) - if (body.network_interfaces?.type === 'default' && anyVpc && anySubnet) { + const niType = body.network_interfaces?.type + if ( + (niType === 'default_ipv4' || niType === 'default_ipv6' || niType === 'default_dual_stack') && + anyVpc && + anySubnet + ) { db.networkInterfaces.push({ id: uuid(), description: 'The default network interface', instance_id: instanceId, primary: true, mac: '00:00:00:00:00:00', - ip: '127.0.0.1', + ip_stack: + niType === 'default_dual_stack' + ? { + type: 'dual_stack', + value: { + v4: { ip: '127.0.0.1', transit_ips: [] }, + v6: { ip: '::1', transit_ips: [] }, + }, + } + : niType === 'default_ipv6' + ? { type: 'v6', value: { ip: '::1', transit_ips: [] } } + : { type: 'v4', value: { ip: '127.0.0.1', transit_ips: [] } }, name: 'default', vpc_id: anyVpc.id, subnet_id: anySubnet.id, @@ -532,7 +606,7 @@ export const handlers = makeHandlers({ }) } else if (body.network_interfaces?.type === 'create') { body.network_interfaces.params.forEach( - ({ name, description, ip, subnet_name, vpc_name }, i) => { + ({ name, description, ip_config, subnet_name, vpc_name }, i) => { db.networkInterfaces.push({ id: uuid(), name, @@ -540,7 +614,12 @@ export const handlers = makeHandlers({ instance_id: instanceId, primary: i === 0 ? true : false, mac: '00:00:00:00:00:00', - ip: ip || '127.0.0.1', + ip_stack: ip_config + ? resolveIpStack(ip_config) + : { + type: 'v4', + value: { ip: '127.0.0.1', transit_ips: [] }, + }, vpc_id: lookup.vpc({ ...query, vpc: vpc_name }).id, subnet_id: lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name }) .id, @@ -561,7 +640,7 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const pool = resolveIpPool(ip.pool) + const pool = resolvePoolSelector(ip.pool_selector) const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -743,7 +822,7 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const pool = resolveIpPool(body.pool) + const pool = resolvePoolSelector(body.pool_selector) const ip = getIpFromPool(pool) const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } @@ -795,7 +874,7 @@ export const handlers = makeHandlers({ ) errIfExists(nicsForInstance, { name: body.name }) - const { name, description, subnet_name, vpc_name, ip } = body + const { name, description, subnet_name, vpc_name, ip_config } = body const vpc = lookup.vpc({ ...query, vpc: vpc_name }) const subnet = lookup.vpcSubnet({ ...query, vpc: vpc_name, subnet: subnet_name }) @@ -807,7 +886,9 @@ export const handlers = makeHandlers({ instance_id: instance.id, name, description, - ip: ip || '123.45.68.8', + ip_stack: ip_config + ? resolveIpStack(ip_config, '123.45.68.8') + : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, vpc_id: vpc.id, subnet_id: subnet.id, mac: '', @@ -842,7 +923,14 @@ export const handlers = makeHandlers({ } if (body.transit_ips) { - nic.transit_ips = body.transit_ips + // TODO: Real API would parse IpNet[] and route IPv4/IPv6 to appropriate stacks. + // For mock, we just put all transit IPs into both stacks. + if (nic.ip_stack.type === 'dual_stack') { + nic.ip_stack.value.v4.transit_ips = body.transit_ips + nic.ip_stack.value.v6.transit_ips = body.transit_ips + } else { + nic.ip_stack.value.transit_ips = body.transit_ips + } } return nic diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index 5c28fafba..bb0f3bc7c 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -17,11 +17,16 @@ export const networkInterface: Json = { description: 'a network interface', primary: true, instance_id: instance.id, - ip: '172.30.0.10', + ip_stack: { + type: 'v4', + value: { + ip: '172.30.0.10', + transit_ips: ['172.30.0.0/22'], + }, + }, mac: '', subnet_id: vpcSubnet.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), - transit_ips: ['172.30.0.0/22'], vpc_id: vpc.id, } diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index dfa036e82..11cdf6b5c 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -51,7 +51,7 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Add network interface"]', 'role=textbox[name="Description"]', - 'role=textbox[name="IP Address"]', + 'role=textbox[name="IP Address (IPv4)"]', ]) await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') From 24045ff3474fb4b7c2a9d90e4c70cea4d9ff7d79 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 9 Jan 2026 15:09:15 -0800 Subject: [PATCH 02/73] Update api generator to 0.13.1 and run --- app/api/__generated__/validate.ts | 85 ++++++++++++---------------- app/forms/network-interface-edit.tsx | 1 - mock-api/msw/handlers.ts | 8 ++- package-lock.json | 31 ++++++++-- package.json | 2 +- 5 files changed, 69 insertions(+), 58 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 579588256..596067ef0 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -186,10 +186,7 @@ export const PoolSelector = z.preprocess( processResponseBody, z.union([ z.object({ pool: NameOrId, type: z.enum(['explicit']) }), - z.object({ - ipVersion: IpVersion.nullable().default(null).optional(), - type: z.enum(['auto']), - }), + z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), ]) ) @@ -205,7 +202,7 @@ export const AddressSelector = z.preprocess( type: z.enum(['explicit']), }), z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), type: z.enum(['auto']), }), ]) @@ -1815,9 +1812,7 @@ export const Distributionint64 = z.preprocess( */ export const EphemeralIpCreate = z.preprocess( processResponseBody, - z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), - }) + z.object({ poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }) }) ) /** @@ -1861,7 +1856,7 @@ export const ExternalIpCreate = z.preprocess( processResponseBody, z.union([ z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), type: z.enum(['ephemeral']), }), z.object({ floatingIp: NameOrId, type: z.enum(['floating']) }), @@ -2017,7 +2012,7 @@ export const FloatingIpCreate = z.preprocess( addressSelector: AddressSelector.default({ poolSelector: { ipVersion: null, type: 'auto' }, type: 'auto', - }).optional(), + }), description: z.string(), name: Name, }) @@ -2272,7 +2267,7 @@ export const Ipv4Assignment = z.preprocess( */ export const PrivateIpv4StackCreate = z.preprocess( processResponseBody, - z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]).optional() }) + z.object({ ip: Ipv4Assignment, transitIps: Ipv4Net.array().default([]) }) ) /** @@ -2291,7 +2286,7 @@ export const Ipv6Assignment = z.preprocess( */ export const PrivateIpv6StackCreate = z.preprocess( processResponseBody, - z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]).optional() }) + z.object({ ip: Ipv6Assignment, transitIps: Ipv6Net.array().default([]) }) ) /** @@ -2322,7 +2317,7 @@ export const InstanceNetworkInterfaceCreate = z.preprocess( v4: { ip: { type: 'auto' }, transitIps: [] }, v6: { ip: { type: 'auto' }, transitIps: [] }, }, - }).optional(), + }), name: Name, subnetName: Name, vpcName: Name, @@ -2349,24 +2344,24 @@ export const InstanceNetworkInterfaceAttachment = z.preprocess( export const InstanceCreate = z.preprocess( processResponseBody, z.object({ - antiAffinityGroups: NameOrId.array().default([]).optional(), - autoRestartPolicy: InstanceAutoRestartPolicy.nullable().default(null).optional(), - bootDisk: InstanceDiskAttachment.nullable().default(null).optional(), - cpuPlatform: InstanceCpuPlatform.nullable().default(null).optional(), + antiAffinityGroups: NameOrId.array().default([]), + autoRestartPolicy: InstanceAutoRestartPolicy.nullable().default(null), + bootDisk: InstanceDiskAttachment.nullable().default(null), + cpuPlatform: InstanceCpuPlatform.nullable().default(null), description: z.string(), - disks: InstanceDiskAttachment.array().default([]).optional(), - externalIps: ExternalIpCreate.array().default([]).optional(), + disks: InstanceDiskAttachment.array().default([]), + externalIps: ExternalIpCreate.array().default([]), hostname: Hostname, memory: ByteCount, - multicastGroups: NameOrId.array().default([]).optional(), + multicastGroups: NameOrId.array().default([]), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ type: 'default_dual_stack', - }).optional(), + }), sshPublicKeys: NameOrId.array().nullable().optional(), - start: SafeBoolean.default(true).optional(), - userData: z.string().default('').optional(), + start: SafeBoolean.default(true), + userData: z.string().default(''), }) ) @@ -2456,8 +2451,8 @@ export const InstanceNetworkInterfaceUpdate = z.preprocess( z.object({ description: z.string().nullable().optional(), name: Name.nullable().optional(), - primary: SafeBoolean.default(false).optional(), - transitIps: IpNet.array().default([]).optional(), + primary: SafeBoolean.default(false), + transitIps: IpNet.array().default([]), }) ) @@ -2487,7 +2482,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().nullable().default(null).optional(), + multicastGroups: NameOrId.array().nullable().default(null), ncpus: InstanceCpuCount, }) ) @@ -2637,9 +2632,9 @@ export const IpPoolCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - ipVersion: IpVersion.default('v4').optional(), + ipVersion: IpVersion.default('v4'), name: Name, - poolType: IpPoolType.default('unicast').optional(), + poolType: IpPoolType.default('unicast'), }) ) @@ -2968,11 +2963,11 @@ export const MulticastGroupCreate = z.preprocess( processResponseBody, z.object({ description: z.string(), - multicastIp: z.ipv4().nullable().default(null).optional(), - mvlan: z.number().min(0).max(65535).nullable().optional(), + multicastIp: z.ipv4().nullable().default(null), + mvlan: z.number().min(0).max(65535).nullable().default(null), name: Name, - pool: NameOrId.nullable().default(null).optional(), - sourceIps: z.ipv4().array().nullable().default(null).optional(), + pool: NameOrId.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), }) ) @@ -3038,11 +3033,7 @@ export const MulticastGroupUpdate = z.preprocess( */ export const PrivateIpv4Config = z.preprocess( processResponseBody, - z.object({ - ip: z.ipv4(), - subnet: Ipv4Net, - transitIps: Ipv4Net.array().default([]).optional(), - }) + z.object({ ip: z.ipv4(), subnet: Ipv4Net, transitIps: Ipv4Net.array().default([]) }) ) /** @@ -3260,7 +3251,7 @@ export const ProbeCreate = z.preprocess( z.object({ description: z.string(), name: Name, - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }).optional(), + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), sled: z.uuid(), }) ) @@ -3527,7 +3518,7 @@ export const SamlIdentityProviderCreate = z.preprocess( idpEntityId: z.string(), idpMetadataSource: IdpMetadataSource, name: Name, - signingKeypair: DerEncodedKeyPair.nullable().default(null).optional(), + signingKeypair: DerEncodedKeyPair.nullable().default(null), sloUrl: z.string(), spClientId: z.string(), technicalContactEmail: z.string(), @@ -3643,9 +3634,7 @@ export const SiloCreate = z.preprocess( description: z.string(), discoverable: SafeBoolean, identityMode: SiloIdentityMode, - mappedFleetRoles: z - .record(z.string(), FleetRole.array().refine(...uniqueItems)) - .optional(), + mappedFleetRoles: z.record(z.string(), FleetRole.array().refine(...uniqueItems)), name: Name, quotas: SiloQuotasCreate, tlsCertificates: CertificateCreate.array(), @@ -4207,14 +4196,14 @@ export const SwitchPortSettingsCreate = z.preprocess( processResponseBody, z.object({ addresses: AddressConfig.array(), - bgpPeers: BgpPeerConfig.array().default([]).optional(), + bgpPeers: BgpPeerConfig.array().default([]), description: z.string(), - groups: NameOrId.array().default([]).optional(), - interfaces: SwitchInterfaceConfigCreate.array().default([]).optional(), + groups: NameOrId.array().default([]), + interfaces: SwitchInterfaceConfigCreate.array().default([]), links: LinkConfigCreate.array(), name: Name, portConfig: SwitchPortConfigCreate, - routes: RouteConfig.array().default([]).optional(), + routes: RouteConfig.array().default([]), }) ) @@ -4674,7 +4663,7 @@ export const VpcFirewallRuleUpdate = z.preprocess( */ export const VpcFirewallRuleUpdateParams = z.preprocess( processResponseBody, - z.object({ rules: VpcFirewallRuleUpdate.array().default([]).optional() }) + z.object({ rules: VpcFirewallRuleUpdate.array().default([]) }) ) /** @@ -4812,7 +4801,7 @@ export const WebhookCreate = z.preprocess( endpoint: z.string(), name: Name, secrets: z.string().array(), - subscriptions: AlertSubscription.array().default([]).optional(), + subscriptions: AlertSubscription.array().default([]), }) ) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 431815a70..1a23c24d1 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -8,7 +8,6 @@ import { useEffect } from 'react' import { useForm } from 'react-hook-form' - import { api, queryClient, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 42f2f9af0..4037a80a2 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -42,7 +42,6 @@ import { lookup, lookupById, notFoundErr, - resolvePoolSelector, utilizationForSilo, } from './db' @@ -309,7 +308,8 @@ export const handlers = makeHandlers({ const pool = addressSelector.type === 'explicit' && addressSelector.pool ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) - : addressSelector.type === 'auto' && addressSelector.pool_selector?.type === 'explicit' + : addressSelector.type === 'auto' && + addressSelector.pool_selector?.type === 'explicit' ? lookup.siloIpPool({ pool: addressSelector.pool_selector.pool, silo: defaultSilo.id, @@ -577,7 +577,9 @@ export const handlers = makeHandlers({ const anySubnet = db.vpcSubnets.find((s) => s.vpc_id === anyVpc?.id) const niType = body.network_interfaces?.type if ( - (niType === 'default_ipv4' || niType === 'default_ipv6' || niType === 'default_dual_stack') && + (niType === 'default_ipv4' || + niType === 'default_ipv6' || + niType === 'default_dual_stack') && anyVpc && anySubnet ) { diff --git a/package-lock.json b/package-lock.json index 921fafc1e..5c6802547 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@oxide/openapi-gen-ts": "~0.13.1", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -361,6 +361,16 @@ "tough-cookie": "^4.1.4" } }, + "node_modules/@commander-js/extra-typings": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@commander-js/extra-typings/-/extra-typings-14.0.0.tgz", + "integrity": "sha512-hIn0ncNaJRLkZrxBIp5AsW/eXEHNKYQBh0aPdoUqNgD+Io3NIykQqpKFyKcuasZhicGaEZJX/JBSIkZ4e5x8Dg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "commander": "~14.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", @@ -1659,13 +1669,13 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.12.0.tgz", - "integrity": "sha512-lebNC+PMbtXc7Ao4fPbJskEGkz6w7yfpaOh/tqboXgo6T3pznSRPF+GJFerGVnU0TRb1SgqItIBccPogsqZiJw==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.13.1.tgz", + "integrity": "sha512-ZVT4vfHrEniWKwwhWEjkaZfbxzjZGPaTjwb4u+I8P5qaT2YkT/D6Y7W/SH4nAOKOSl1PZUIDgfjVQ9rDh8CYKA==", "dev": true, "license": "MPL-2.0", "dependencies": { - "minimist": "^1.2.8", + "@commander-js/extra-typings": "^14.0.0", "prettier": "2.7.1", "swagger-parser": "^10.0.3", "ts-pattern": "^5.1.1" @@ -7045,6 +7055,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", + "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", diff --git a/package.json b/package.json index 85af5cf5e..0ebbfddda 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.12.0", + "@oxide/openapi-gen-ts": "~0.13.1", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", From 7674539d2ca5a85ba68d0e28f6d2edcd5c6b3ccc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Jan 2026 12:14:45 -0800 Subject: [PATCH 03/73] Remove unused helper --- app/util/ip-stack.ts | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 app/util/ip-stack.ts diff --git a/app/util/ip-stack.ts b/app/util/ip-stack.ts deleted file mode 100644 index d26461112..000000000 --- a/app/util/ip-stack.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -import type { InstanceNetworkInterface } from '@oxide/api' - -/** - * Extract the primary IP address from an instance network interface's IP stack. - * For dual-stack, returns the IPv4 address. - */ -export function getIpFromStack(nic: InstanceNetworkInterface): string { - if (nic.ipStack.type === 'dual_stack') { - return nic.ipStack.value.v4.ip - } - return nic.ipStack.value.ip -} - -/** - * Extract transit IPs from an instance network interface's IP stack. - * For dual-stack, returns the IPv4 transit IPs (since transit IPs are generally version-agnostic in the UI). - */ -export function getTransitIpsFromStack(nic: InstanceNetworkInterface): string[] { - if (nic.ipStack.type === 'dual_stack') { - return nic.ipStack.value.v4.transitIps - } - return nic.ipStack.value.transitIps -} From 1468da64f0a297103e80572f55d65c2bb9470b8a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 12 Jan 2026 13:13:01 -0800 Subject: [PATCH 04/73] simpler handling, as action is impossible without a pool --- app/components/AttachEphemeralIpModal.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 24f8aec03..fd4bb9768 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,14 +65,13 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) { - if (!pool) return + onAction={() => instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool } }, + body: { poolSelector: { type: 'explicit', pool: pool! } }, }) - }} + } onDismiss={onDismiss} > From 2b49c3fd262188dc218a062549505eb4a7a3d025 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 14 Jan 2026 15:12:06 -0800 Subject: [PATCH 05/73] Updates to Networking Interfaces table --- app/forms/network-interface-edit.tsx | 2 +- app/pages/project/instances/NetworkingTab.tsx | 28 ++++++++++++++----- mock-api/network-interface.ts | 12 ++++++-- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 1a23c24d1..619e082b8 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -54,7 +54,7 @@ export function EditNetworkInterfaceForm({ // Extract transitIps from ipStack for the form const extractedTransitIps = editing.ipStack.type === 'dual_stack' - ? editing.ipStack.value.v4.transitIps + ? [...editing.ipStack.value.v4.transitIps, ...editing.ipStack.value.v6.transitIps] : editing.ipStack.value.transitIps const defaultValues = { diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 6f0395121..fed676c51 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -157,9 +157,18 @@ const staticCols = [ header: 'Private IP', cell: (info) => { const nic = info.row.original - const ip = - nic.ipStack.type === 'dual_stack' ? nic.ipStack.value.v4.ip : nic.ipStack.value.ip - return + const { ipStack } = nic + + if (ipStack.type === 'dual_stack') { + return ( +
+ + +
+ ) + } + + return }, }), colHelper.accessor('vpcId', { @@ -175,10 +184,15 @@ const staticCols = [ header: 'Transit IPs', cell: (info) => { const nic = info.row.original - const transitIps = - nic.ipStack.type === 'dual_stack' - ? nic.ipStack.value.v4.transitIps - : nic.ipStack.value.transitIps + const { ipStack } = nic + + let transitIps: string[] = [] + if (ipStack.type === 'v4' || ipStack.type === 'v6') { + transitIps = ipStack.value.transitIps + } else if (ipStack.type === 'dual_stack') { + // Combine both v4 and v6 transit IPs for dual-stack + transitIps = [...ipStack.value.v4.transitIps, ...ipStack.value.v6.transitIps] + } return ( {transitIps?.map((ip) => ( diff --git a/mock-api/network-interface.ts b/mock-api/network-interface.ts index bb0f3bc7c..3734b51ae 100644 --- a/mock-api/network-interface.ts +++ b/mock-api/network-interface.ts @@ -18,10 +18,16 @@ export const networkInterface: Json = { primary: true, instance_id: instance.id, ip_stack: { - type: 'v4', + type: 'dual_stack', value: { - ip: '172.30.0.10', - transit_ips: ['172.30.0.0/22'], + v4: { + ip: '172.30.0.10', + transit_ips: ['172.30.0.0/22'], + }, + v6: { + ip: '::1', + transit_ips: ['::/64'], + }, }, }, mac: '', From df1bdbf37b44e182f098c9d87fd57d30a196a9b0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 09:50:54 -0800 Subject: [PATCH 06/73] Update tests --- app/pages/project/instances/NetworkingTab.tsx | 2 +- mock-api/msw/handlers.ts | 21 +++++++++++-------- test/e2e/instance-networking.e2e.ts | 7 ++++--- test/e2e/network-interface-create.e2e.ts | 4 ++-- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index fed676c51..f3a558d83 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -161,7 +161,7 @@ const staticCols = [ if (ipStack.type === 'dual_stack') { return ( -
+
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 4037a80a2..efc531fef 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -87,7 +87,8 @@ const resolveIpStack = ( type: 'dual_stack' value: { v4: Api.PrivateIpv4StackCreate; v6: Api.PrivateIpv6StackCreate } }, - defaultIp = '127.0.0.1' + defaultV4Ip = '127.0.0.1', + defaultV6Ip = '::1' ): | { type: 'v4'; value: { ip: string; transit_ips: string[] } } | { type: 'v6'; value: { ip: string; transit_ips: string[] } } @@ -103,11 +104,11 @@ const resolveIpStack = ( type: 'dual_stack', value: { v4: { - ip: resolveIp(config.value.v4.ip, defaultIp), + ip: resolveIp(config.value.v4.ip, defaultV4Ip), transit_ips: config.value.v4.transitIps || [], }, v6: { - ip: resolveIp(config.value.v6.ip, defaultIp), + ip: resolveIp(config.value.v6.ip, defaultV6Ip), transit_ips: config.value.v6.transitIps || [], }, }, @@ -116,7 +117,7 @@ const resolveIpStack = ( return { type: config.type, value: { - ip: resolveIp(config.value.ip, defaultIp), + ip: resolveIp(config.value.ip, config.type === 'v6' ? defaultV6Ip : defaultV4Ip), transit_ips: config.value.transitIps || [], }, } @@ -889,7 +890,7 @@ export const handlers = makeHandlers({ name, description, ip_stack: ip_config - ? resolveIpStack(ip_config, '123.45.68.8') + ? resolveIpStack(ip_config, '123.45.68.8', 'fd12:3456::') : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, vpc_id: vpc.id, subnet_id: subnet.id, @@ -925,11 +926,13 @@ export const handlers = makeHandlers({ } if (body.transit_ips) { - // TODO: Real API would parse IpNet[] and route IPv4/IPv6 to appropriate stacks. - // For mock, we just put all transit IPs into both stacks. if (nic.ip_stack.type === 'dual_stack') { - nic.ip_stack.value.v4.transit_ips = body.transit_ips - nic.ip_stack.value.v6.transit_ips = body.transit_ips + // Separate IPv4 and IPv6 transit IPs + const [v6TransitIps, v4TransitIps] = R.partition(body.transit_ips, (ip) => + ip.includes(':') + ) + nic.ip_stack.value.v4.transit_ips = v4TransitIps + nic.ip_stack.value.v6.transit_ips = v6TransitIps } else { nic.ip_stack.value.transit_ips = body.transit_ips } diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 11cdf6b5c..53d3df621 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -266,11 +266,12 @@ test('Edit network interface - Transit IPs', async ({ page }) => { await modal.getByRole('button', { name: 'Update network interface' }).click() // Assert the transit IP is in the NICs table + // The NIC now has 3 transit IPs: 172.30.0.0/22 (v4), 192.168.0.0/16 (v4), and ::/64 (v6) const nicTable = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+1' }) + await expectRowVisible(nicTable, { 'Transit IPs': '172.30.0.0/22+2' }) - await page.getByText('+1').hover() + await page.getByText('+2').hover() await expect( - page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16' }) + page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16 ::/64' }) ).toBeVisible() }) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index c735553fe..8565716ce 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -69,7 +69,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await sidebar.getByRole('button', { name: 'Add network interface' }).click() await expect(sidebar).toBeHidden() - // ip address is auto-assigned + // ip address is auto-assigned (dual-stack by default) const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8' }) + await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) }) From f2519242275cd380a28dc6e6e1276d41e13ed11a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 14:22:10 -0800 Subject: [PATCH 07/73] Update form with IPv4, IPv6, dual stack --- app/forms/network-interface-create.tsx | 97 ++++++++++++++++++++---- mock-api/msw/handlers.ts | 9 ++- test/e2e/instance-networking.e2e.ts | 3 +- test/e2e/network-interface-create.e2e.ts | 69 +++++++++++++++-- 4 files changed, 155 insertions(+), 23 deletions(-) diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 2fc75a634..edb4f99c9 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -14,18 +14,23 @@ import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxi import { DescriptionField } from '~/components/form/fields/DescriptionField' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' +import { RadioField } from '~/components/form/fields/RadioField' import { SubnetListbox } from '~/components/form/fields/SubnetListbox' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' import { FormDivider } from '~/ui/lib/Divider' +type IpStackType = 'v4' | 'v6' | 'dual_stack' + const defaultValues = { name: '', description: '', subnetName: '', vpcName: '', - ip: '', + ipStackType: 'dual_stack' as IpStackType, + ipv4: '', + ipv6: '', } type CreateNetworkInterfaceFormProps = { @@ -51,6 +56,7 @@ export function CreateNetworkInterfaceForm({ const vpcs = useMemo(() => vpcsData?.items || [], [vpcsData]) const form = useForm({ defaultValues }) + const ipStackType = form.watch('ipStackType') return ( { - // Transform to IPv4 ipConfig structure - const ipConfig = ip.trim() - ? { - type: 'v4' as const, - value: { - ip: { type: 'explicit' as const, value: ip.trim() }, + onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => { + // Build ipConfig based on the selected IP stack type + let ipConfig: InstanceNetworkInterfaceCreate['ipConfig'] + + if (ipStackType === 'v4') { + ipConfig = { + type: 'v4', + value: { + ip: ipv4.trim() ? { type: 'explicit', value: ipv4.trim() } : { type: 'auto' }, + transitIps: [], + }, + } + } else if (ipStackType === 'v6') { + ipConfig = { + type: 'v6', + value: { + ip: ipv6.trim() ? { type: 'explicit', value: ipv6.trim() } : { type: 'auto' }, + transitIps: [], + }, + } + } else { + // dual_stack + ipConfig = { + type: 'dual_stack', + value: { + v4: { + ip: ipv4.trim() + ? { type: 'explicit', value: ipv4.trim() } + : { type: 'auto' }, + transitIps: [], + }, + v6: { + ip: ipv6.trim() + ? { type: 'explicit', value: ipv6.trim() } + : { type: 'auto' }, transitIps: [], }, - } - : undefined + }, + } + } + onSubmit({ ...rest, ipConfig }) }} loading={loading} @@ -94,12 +130,45 @@ export function CreateNetworkInterfaceForm({ required control={form.control} /> - + + {(ipStackType === 'v4' || ipStackType === 'dual_stack') && ( + + )} + + {(ipStackType === 'v6' || ipStackType === 'dual_stack') && ( + + )} ) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 22bd59a08..82dde2650 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -891,7 +891,14 @@ export const handlers = makeHandlers({ description, ip_stack: ip_config ? resolveIpStack(ip_config, '123.45.68.8', 'fd12:3456::') - : { type: 'v4', value: { ip: '123.45.68.8', transit_ips: [] } }, + : // Default is dual-stack with auto-assigned IPs + { + type: 'dual_stack', + value: { + v4: { ip: '123.45.68.8', transit_ips: [] }, + v6: { ip: 'fd12:3456::', transit_ips: [] }, + }, + }, vpc_id: vpc.id, subnet_id: subnet.id, mac: '', diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 53d3df621..3079160e9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -51,7 +51,8 @@ test('Instance networking tab — NIC table', async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Add network interface"]', 'role=textbox[name="Description"]', - 'role=textbox[name="IP Address (IPv4)"]', + 'role=textbox[name="IPv4 Address"]', + 'role=textbox[name="IPv6 Address"]', ]) await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index 8565716ce..b68f427b4 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -10,7 +10,7 @@ import { test } from '@playwright/test' import { expect, expectRowVisible, stopInstance } from './utils' test('can create a NIC with a specified IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -24,7 +24,10 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await page.getByRole('option', { name: 'mock-vpc' }).click() await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - await page.getByLabel('IP Address').fill('1.2.3.4') + + // Select IPv4 only + await page.getByRole('radio', { name: 'IPv4', exact: true }).click() + await page.getByLabel('IPv4 Address').fill('1.2.3.4') const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -37,7 +40,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { }) test('can create a NIC with a blank IP address', async ({ page }) => { - // go to an instance’s Network Interfaces page + // go to an instance's Network Interfaces page await page.goto('/projects/mock-project/instances/db1/networking') await stopInstance(page) @@ -52,8 +55,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await page.getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet' }).click() - // make sure the IP address field has a non-conforming bit of text in it - await page.getByLabel('IP Address').fill('x') + // Dual-stack is selected by default, so both fields should be visible + // make sure the IPv4 address field has a non-conforming bit of text in it + await page.getByLabel('IPv4 Address').fill('x') // try to submit it const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) @@ -62,8 +66,9 @@ test('can create a NIC with a blank IP address', async ({ page }) => { // it should error out await expect(sidebar.getByText('Zod error for body')).toBeVisible() - // make sure the IP address field has spaces in it - await page.getByLabel('IP Address').fill(' ') + // make sure both IP address fields have spaces in them + await page.getByLabel('IPv4 Address').fill(' ') + await page.getByLabel('IPv6 Address').fill(' ') // test that the form can be submitted and a new network interface is created await sidebar.getByRole('button', { name: 'Add network interface' }).click() @@ -73,3 +78,53 @@ test('can create a NIC with a blank IP address', async ({ page }) => { const table = page.getByRole('table', { name: 'Network interfaces' }) await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) }) + +test('can create a NIC with IPv6 only', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + await stopInstance(page) + + await page.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-3') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6 only + await page.getByRole('radio', { name: 'IPv6', exact: true }).click() + await page.getByLabel('IPv6 Address').fill('::1') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + + const table = page.getByRole('table', { name: 'Network interfaces' }) + await expectRowVisible(table, { name: 'nic-3', 'Private IP': '::1' }) +}) + +test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + await stopInstance(page) + + await page.getByRole('button', { name: 'Add network interface' }).click() + + await page.getByLabel('Name').fill('nic-4') + await page.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Dual-stack is selected by default + await page.getByLabel('IPv4 Address').fill('10.0.0.5') + await page.getByLabel('IPv6 Address').fill('fd00::5') + + const sidebar = page.getByRole('dialog', { name: 'Add network interface' }) + await sidebar.getByRole('button', { name: 'Add network interface' }).click() + await expect(sidebar).toBeHidden() + + const table = page.getByRole('table', { name: 'Network interfaces' }) + await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' }) +}) From 49c1dcf96d219dd774616c74f29638d0e7939e13 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 15:17:05 -0800 Subject: [PATCH 08/73] proper v4 vs v6 filtering --- app/components/AttachEphemeralIpModal.tsx | 7 ++++--- app/forms/floating-ip-create.tsx | 15 ++++++--------- mock-api/msw/handlers.ts | 11 +++++++---- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index fd4bb9768..24f8aec03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,13 +65,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) + onAction={() => { + if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool: pool! } }, + body: { poolSelector: { type: 'explicit', pool } }, }) - } + }} onDismiss={onDismiss} > diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 4ff110568..5c30c3f7c 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,10 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues: FloatingIpCreate = { +const defaultValues = { name: '', description: '', - addressSelector: undefined, + pool: '', } export const handle = titleCrumb('New Floating IP') @@ -65,12 +65,9 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={(values) => { - // Transform the form values to properly construct addressSelector - const pool = form.getValues('addressSelector.poolSelector.pool' as any) - const body = { - name: values.name, - description: values.description, + onSubmit={({ pool, ...values }) => { + const body: FloatingIpCreate = { + ...values, addressSelector: pool ? { type: 'auto' as const, @@ -103,7 +100,7 @@ export default function CreateFloatingIpSideModalForm() { /> - ip.includes(':') - ) + // Parse and separate IPv4 and IPv6 transit IPs using proper IP parsing + // This matches how the real API routes IpNet[] to the appropriate stacks + const [v6TransitIps, v4TransitIps] = R.partition(body.transit_ips, (ipNet) => { + const parsed = parseIpNet(ipNet) + return parsed.type === 'v6' + }) nic.ip_stack.value.v4.transit_ips = v4TransitIps nic.ip_stack.value.v6.transit_ips = v6TransitIps } else { From ed375cf85aaf9fc313394c6638551f406242ee90 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 16 Jan 2026 16:26:48 -0800 Subject: [PATCH 09/73] update types in form --- app/forms/floating-ip-create.tsx | 9 +++- app/forms/network-interface-create.tsx | 69 ++++++++++++-------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 5c30c3f7c..9ff4dae77 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -27,10 +27,15 @@ import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' -const defaultValues = { +type FloatingIpCreateFormData = { + name: string + description: string + pool?: string +} + +const defaultValues: FloatingIpCreateFormData = { name: '', description: '', - pool: '', } export const handle = titleCrumb('New Floating IP') diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index edb4f99c9..b675beb02 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { useMemo } from 'react' import { useForm } from 'react-hook-form' +import { match } from 'ts-pattern' import { api, q, type ApiError, type InstanceNetworkInterfaceCreate } from '@oxide/api' @@ -33,6 +34,22 @@ const defaultValues = { ipv6: '', } +// Helper to build IP assignment from string +function buildIpAssignment( + ipString: string +): { type: 'auto' } | { type: 'explicit'; value: string } { + const trimmed = ipString.trim() + return trimmed ? { type: 'explicit', value: trimmed } : { type: 'auto' } +} + +// Helper to build a single IP stack (v4 or v6) +function buildIpStack(ipString: string) { + return { + ip: buildIpAssignment(ipString), + transitIps: [], + } +} + type CreateNetworkInterfaceFormProps = { onDismiss: () => void onSubmit: (values: InstanceNetworkInterfaceCreate) => void @@ -66,45 +83,23 @@ export function CreateNetworkInterfaceForm({ title="Add network interface" onDismiss={onDismiss} onSubmit={({ ipStackType, ipv4, ipv6, ...rest }) => { - // Build ipConfig based on the selected IP stack type - let ipConfig: InstanceNetworkInterfaceCreate['ipConfig'] - - if (ipStackType === 'v4') { - ipConfig = { - type: 'v4', - value: { - ip: ipv4.trim() ? { type: 'explicit', value: ipv4.trim() } : { type: 'auto' }, - transitIps: [], - }, - } - } else if (ipStackType === 'v6') { - ipConfig = { - type: 'v6', - value: { - ip: ipv6.trim() ? { type: 'explicit', value: ipv6.trim() } : { type: 'auto' }, - transitIps: [], - }, - } - } else { - // dual_stack - ipConfig = { - type: 'dual_stack', + const ipConfig = match(ipStackType) + .with('v4', () => ({ + type: 'v4' as const, + value: buildIpStack(ipv4), + })) + .with('v6', () => ({ + type: 'v6' as const, + value: buildIpStack(ipv6), + })) + .with('dual_stack', () => ({ + type: 'dual_stack' as const, value: { - v4: { - ip: ipv4.trim() - ? { type: 'explicit', value: ipv4.trim() } - : { type: 'auto' }, - transitIps: [], - }, - v6: { - ip: ipv6.trim() - ? { type: 'explicit', value: ipv6.trim() } - : { type: 'auto' }, - transitIps: [], - }, + v4: buildIpStack(ipv4), + v6: buildIpStack(ipv6), }, - } - } + })) + .exhaustive() onSubmit({ ...rest, ipConfig }) }} From 8546afe59573360a7244e0891184a590fd1125bd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sat, 17 Jan 2026 00:04:45 -0800 Subject: [PATCH 10/73] more defaults --- .../form/fields/NetworkInterfaceField.tsx | 54 +++++++++++++++---- app/forms/instance-create.tsx | 2 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 3de33ddbe..b12fb17bc 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,10 +8,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { - InstanceNetworkInterfaceAttachment, - InstanceNetworkInterfaceCreate, -} from '@oxide/api' +import type { InstanceNetworkInterfaceCreate } from '@oxide/api' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -44,6 +41,17 @@ export function NetworkInterfaceField({ field: { value, onChange }, } = useController({ control, name: 'networkInterfaces' }) + // Map API types to radio values + // 'default_ipv4' | 'default_ipv6' | 'default_dual_stack' all map to 'default' + const radioValue = + value.type === 'default_ipv4' || + value.type === 'default_ipv6' || + value.type === 'default_dual_stack' + ? 'default' + : value.type + + const isDefaultSelected = radioValue === 'default' + return (
Network interface @@ -53,18 +61,21 @@ export function NetworkInterfaceField({ name="networkInterfaceType" column className="pt-1" - defaultChecked={value.type} + defaultChecked={radioValue} onChange={(event) => { - const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] + const radioSelection = event.target.value if (value.type === 'create') { setOldParams(value.params) } - if (newType === 'create') { - onChange({ type: newType, params: oldParams }) - } else { - onChange({ type: newType }) + if (radioSelection === 'create') { + onChange({ type: 'create', params: oldParams }) + } else if (radioSelection === 'default') { + // When user selects 'default', use dual_stack as the default + onChange({ type: 'default_dual_stack' }) + } else if (radioSelection === 'none') { + onChange({ type: 'none' }) } }} disabled={disabled} @@ -73,6 +84,29 @@ export function NetworkInterfaceField({ Default Custom + {isDefaultSelected && ( +
+ { + const ipVersionType = event.target.value as + | 'default_ipv4' + | 'default_ipv6' + | 'default_dual_stack' + onChange({ type: ipVersionType }) + }} + disabled={disabled} + > + IPv4 & IPv6 + IPv4 + IPv6 + +
+ )} {value.type === 'create' && ( <> Date: Sat, 17 Jan 2026 06:15:12 -0800 Subject: [PATCH 11/73] flatten default options --- .../form/fields/NetworkInterfaceField.tsx | 51 +++---------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index b12fb17bc..6893c6215 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -41,17 +41,6 @@ export function NetworkInterfaceField({ field: { value, onChange }, } = useController({ control, name: 'networkInterfaces' }) - // Map API types to radio values - // 'default_ipv4' | 'default_ipv6' | 'default_dual_stack' all map to 'default' - const radioValue = - value.type === 'default_ipv4' || - value.type === 'default_ipv6' || - value.type === 'default_dual_stack' - ? 'default' - : value.type - - const isDefaultSelected = radioValue === 'default' - return (
Network interface @@ -61,52 +50,28 @@ export function NetworkInterfaceField({ name="networkInterfaceType" column className="pt-1" - defaultChecked={radioValue} + defaultChecked={value.type} onChange={(event) => { - const radioSelection = event.target.value + const newType = event.target.value if (value.type === 'create') { setOldParams(value.params) } - if (radioSelection === 'create') { + if (newType === 'create') { onChange({ type: 'create', params: oldParams }) - } else if (radioSelection === 'default') { - // When user selects 'default', use dual_stack as the default - onChange({ type: 'default_dual_stack' }) - } else if (radioSelection === 'none') { - onChange({ type: 'none' }) + } else { + onChange({ type: newType as typeof value.type }) } }} disabled={disabled} > + Default IPv4 & IPv6 + Default IPv4 + Default IPv6 None - Default Custom - {isDefaultSelected && ( -
- { - const ipVersionType = event.target.value as - | 'default_ipv4' - | 'default_ipv6' - | 'default_dual_stack' - onChange({ type: ipVersionType }) - }} - disabled={disabled} - > - IPv4 & IPv6 - IPv4 - IPv6 - -
- )} {value.type === 'create' && ( <> Date: Sat, 17 Jan 2026 07:25:34 -0800 Subject: [PATCH 12/73] Add instance create tests --- test/e2e/instance-create.e2e.ts | 109 ++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index ee3c74890..cc653ff48 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -676,3 +676,112 @@ test('Validate CPU and RAM', async ({ page }) => { await expect(cpuMsg).toBeVisible() await expect(memMsg).toBeVisible() }) + +test('create instance with IPv6-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv6-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv6" network interface + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv6-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv6 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /::/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv4 address is shown (no periods in a dotted-decimal format within the Private IP) + // We check that the cell with IPv6 doesn't also contain IPv4 + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/::/) + expect(cellText).not.toMatch(/\d+\.\d+\.\d+\.\d+/) +}) + +test('create instance with IPv4-only networking', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ipv4-only-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Default IPv4" network interface + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/ipv4-only-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify the Private IP column exists and contains an IPv4 address + const privateIpCell = nicTable.getByRole('cell').filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCell.first()).toBeVisible() + + // Verify no IPv6 address is shown (no colons in IPv6 format within the Private IP) + const cellText = await privateIpCell.first().textContent() + expect(cellText).toMatch(/\d+\.\d+\.\d+\.\d+/) + expect(cellText).not.toMatch(/::/) +}) + +test('create instance with dual-stack networking shows both IPs', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'dual-stack-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Default is already "Default IPv4 & IPv6", so no need to select it + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + + await expect(page).toHaveURL(/\/instances\/dual-stack-instance/) + + // Navigate to the Networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Check that the network interfaces table shows up + const nicTable = page.getByRole('table', { name: 'Network interfaces' }) + await expect(nicTable).toBeVisible() + + // Verify both IPv4 and IPv6 addresses are shown + const privateIpCells = nicTable + .locator('tbody tr') + .first() + .locator('td') + .filter({ hasText: /127\.0\.0\.1/ }) + await expect(privateIpCells.first()).toBeVisible() + + // Check that the same cell contains IPv6 + const cellText = await privateIpCells.first().textContent() + expect(cellText).toMatch(/127\.0\.0\.1/) // IPv4 + expect(cellText).toMatch(/::1/) // IPv6 +}) From f38bf20bd925b7e38d2ae04c713da2e72ef3fc5e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 09:31:29 -0800 Subject: [PATCH 13/73] Update to latest Omicron; npm run gen-api --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 377 ++++++++++---------------- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 83 +----- app/api/__generated__/validate.ts | 231 ++++++---------- app/forms/floating-ip-create.tsx | 2 +- mock-api/msw/handlers.ts | 20 +- 7 files changed, 240 insertions(+), 477 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 2a5ae4903..be6a09b19 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -135be59591f1ba4bc5941f63bf3a08a0b187b1a9 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index db95aa333..445f5de6d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -58,6 +58,49 @@ export type Address = { vlanId?: number | null } +/** + * The IP address version. + */ +export type IpVersion = 'v4' | 'v6' + +/** + * Specify which IP pool to allocate from. + */ +export type PoolSelector = + /** Use the specified pool by name or ID. */ + | { + /** The pool to allocate from. */ + pool: NameOrId + type: 'explicit' + } + /** Use the default pool for the silo. */ + | { + /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ + ipVersion?: IpVersion | null + type: 'auto' + } + +/** + * Specify how to allocate a floating IP address. + */ +export type AddressAllocator = + /** Reserve a specific IP address. */ + | { + /** The IP address to reserve. Must be available in the pool. */ + ip: string + /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ + pool?: NameOrId | null + type: 'explicit' + } + /** Automatically allocate an IP address from a specified pool. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + type: 'auto' + } + /** * A set of addresses associated with a port configuration. */ @@ -170,49 +213,6 @@ export type AddressLotViewResponse = { lot: AddressLot } -/** - * The IP address version. - */ -export type IpVersion = 'v4' | 'v6' - -/** - * Specify which IP pool to allocate from. - */ -export type PoolSelector = - /** Use the specified pool by name or ID. */ - | { - /** The pool to allocate from. */ - pool: NameOrId - type: 'explicit' - } - /** Use the default pool for the silo. */ - | { - /** IP version to use when multiple default pools exist. Required if both IPv4 and IPv6 default pools are configured. */ - ipVersion?: IpVersion | null - type: 'auto' - } - -/** - * Specify how to allocate a floating IP address. - */ -export type AddressSelector = - /** Reserve a specific IP address. */ - | { - /** The IP address to reserve. Must be available in the pool. */ - ip: string - /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ - pool?: NameOrId | null - type: 'explicit' - } - /** Automatically allocate an IP address from a specified pool. */ - | { - /** Pool selection. - -If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ - poolSelector?: PoolSelector - type: 'auto' - } - /** * Describes the scope of affinity for the purposes of co-location. */ @@ -675,6 +675,19 @@ export type AuditLogEntryActor = | { kind: 'scim'; siloId: string } | { kind: 'unauthenticated' } +/** + * Authentication method used for a request + */ +export type AuthMethod = + /** Console session cookie */ + | 'session_cookie' + + /** Device access token (OAuth 2.0 device authorization flow) */ + | 'access_token' + + /** SCIM client bearer token */ + | 'scim_token' + /** * Result of an audit log entry */ @@ -701,8 +714,10 @@ export type AuditLogEntryResult = */ export type AuditLogEntry = { actor: AuditLogEntryActor - /** How the user authenticated the request. Possible values are "session_cookie" and "access_token". Optional because it will not be defined on unauthenticated requests like login attempts. */ - authMethod?: string | null + /** How the user authenticated the request (access token, session, or SCIM token). Null for unauthenticated requests like login attempts. */ + authMethod?: AuthMethod | null + /** ID of the credential used for authentication. Null for unauthenticated requests. The value of `auth_method` indicates what kind of credential it is (access token, session, or SCIM token). */ + credentialId?: string | null /** Unique identifier for the audit log entry */ id: string /** API endpoint ID, e.g., `project_create` */ @@ -2174,7 +2189,7 @@ export type FloatingIpAttach = { */ export type FloatingIpCreate = { /** IP address allocation method. */ - addressSelector?: AddressSelector + addressAllocator?: AddressAllocator description: string name: Name } @@ -2427,6 +2442,27 @@ export type InstanceDiskAttachment = type: 'attach' } +/** + * A multicast group identifier + * + * Can be a UUID, a name, or an IP address + */ +export type MulticastGroupIdentifier = string + +/** + * Specification for joining a multicast group with optional source filtering. + * + * Used in `InstanceCreate` and `InstanceUpdate` to specify multicast group membership along with per-member source IP configuration. + */ +export type MulticastGroupJoinSpec = { + /** The multicast group to join, specified by name, UUID, or IP address. */ + group: MulticastGroupIdentifier + /** IP version for pool selection when creating a group by name. Required if both IPv4 and IPv6 default multicast pools are linked. */ + ipVersion?: IpVersion | null + /** Source IPs for source-filtered multicast (SSM). Optional for ASM groups, required for SSM groups (232.0.0.0/8, ff3x::/32). */ + sourceIps?: string[] | null +} + /** * How a VPC-private IP address is assigned to a network interface. */ @@ -2555,10 +2591,10 @@ By default, all instances have outbound connectivity, but no inbound connectivit hostname: Hostname /** The amount of RAM (in bytes) to be allocated to the instance */ memory: ByteCount - /** The multicast groups this instance should join. + /** Multicast groups this instance should join at creation. -The instance will be automatically added as a member of the specified multicast groups during creation, enabling it to send and receive multicast traffic for those groups. */ - multicastGroups?: NameOrId[] +Groups can be specified by name, UUID, or IP address. Non-existent groups are created automatically. */ + multicastGroups?: MulticastGroupJoinSpec[] name: Name /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount @@ -2574,6 +2610,18 @@ If not provided, all SSH public keys from the user's profile will be sent. If an userData?: string } +/** + * Parameters for joining an instance to a multicast group. + * + * When joining by IP address, the pool containing the multicast IP is auto-discovered from all linked multicast pools. + */ +export type InstanceMulticastGroupJoin = { + /** IP version for pool selection when creating a group by name. Required if both IPv4 and IPv6 default multicast pools are linked. */ + ipVersion?: IpVersion | null + /** Source IPs for source-filtered multicast (SSM). Optional for ASM groups, required for SSM groups (232.0.0.0/8, ff3x::/32). */ + sourceIps?: string[] | null +} + /** * The VPC-private IPv4 stack for a network interface */ @@ -2712,8 +2760,10 @@ An instance that does not have a boot disk set will use the boot options specifi When specified, this replaces the instance's current multicast group membership with the new set of groups. The instance will leave any groups not listed here and join any new groups that are specified. -If not provided (None), the instance's multicast group membership will not be changed. */ - multicastGroups?: NameOrId[] | null +Each entry can specify the group by name, UUID, or IP address, along with optional source IP filtering for SSM (Source-Specific Multicast). When a group doesn't exist, it will be implicitly created using the default multicast pool (or you can specify `ip_version` to disambiguate if needed). + +If not provided, the instance's multicast group membership will not be changed. */ + multicastGroups?: MulticastGroupJoinSpec[] | null /** The number of vCPUs to be allocated to the instance */ ncpus: InstanceCpuCount } @@ -3240,7 +3290,9 @@ export type MulticastGroup = { mvlan?: number | null /** unique, mutable, user-controlled identifier for each resource */ name: Name - /** Source IP addresses for Source-Specific Multicast (SSM). Empty array means any source is allowed. */ + /** Union of all member source IP addresses (computed, read-only). + +This field shows the combined source IPs across all group members. Individual members may subscribe to different sources; this union reflects all sources that any member is subscribed to. Empty array means no members have source filtering enabled. */ sourceIps: string[] /** Current state of the multicast group. */ state: string @@ -3250,26 +3302,6 @@ export type MulticastGroup = { timeModified: Date } -/** - * Create-time parameters for a multicast group. - */ -export type MulticastGroupCreate = { - description: string - /** The multicast IP address to allocate. If None, one will be allocated from the default pool. */ - multicastIp?: string | null - /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. Tags packets leaving the rack to traverse VLAN-segmented upstream networks. - -Valid range: 2-4094 (VLAN IDs 0-1 are reserved by IEEE 802.1Q standard). */ - mvlan?: number | null - name: Name - /** Name or ID of the IP pool to allocate from. If None, uses the default multicast pool. */ - pool?: NameOrId | null - /** Source IP addresses for Source-Specific Multicast (SSM). - -None uses default behavior (Any-Source Multicast). Empty list explicitly allows any source (Any-Source Multicast). Non-empty list restricts to specific sources (SSM). */ - sourceIps?: string[] | null -} - /** * View of a Multicast Group Member (instance belonging to a multicast group) */ @@ -3282,8 +3314,14 @@ export type MulticastGroupMember = { instanceId: string /** The ID of the multicast group this member belongs to. */ multicastGroupId: string + /** The multicast IP address of the group this member belongs to. */ + multicastIp: string /** unique, mutable, user-controlled identifier for each resource */ name: Name + /** Source IP addresses for this member's multicast subscription. + +- **ASM**: Sources are optional. Empty array means any source is allowed. Non-empty array enables source filtering (IGMPv3/MLDv2). - **SSM**: Sources are required for SSM addresses (232/8, ff3x::/32). */ + sourceIps: string[] /** Current state of the multicast group membership. */ state: string /** timestamp when this resource was created */ @@ -3292,14 +3330,6 @@ export type MulticastGroupMember = { timeModified: Date } -/** - * Parameters for adding an instance to a multicast group. - */ -export type MulticastGroupMemberAdd = { - /** Name or ID of the instance to add to the multicast group */ - instance: NameOrId -} - /** * A single page of results */ @@ -3320,17 +3350,6 @@ export type MulticastGroupResultsPage = { nextPage?: string | null } -/** - * Update-time parameters for a multicast group. - */ -export type MulticastGroupUpdate = { - description?: string | null - /** Multicast VLAN (MVLAN) for egress multicast traffic to upstream networks. Set to null to clear the MVLAN. Valid range: 2-4094 when provided. Omit the field to leave mvlan unchanged. */ - mvlan?: number | null - name?: Name | null - sourceIps?: string[] | null -} - /** * VPC-private IPv4 configuration for a network interface. */ @@ -5952,12 +5971,15 @@ export interface InstanceMulticastGroupListPathParams { } export interface InstanceMulticastGroupListQueryParams { + limit?: number | null + pageToken?: string | null project?: NameOrId + sortBy?: IdSortMode } export interface InstanceMulticastGroupJoinPathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupJoinQueryParams { @@ -5966,7 +5988,7 @@ export interface InstanceMulticastGroupJoinQueryParams { export interface InstanceMulticastGroupLeavePathParams { instance: NameOrId - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface InstanceMulticastGroupLeaveQueryParams { @@ -6176,19 +6198,11 @@ export interface MulticastGroupListQueryParams { } export interface MulticastGroupViewPathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupUpdatePathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupDeletePathParams { - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface MulticastGroupMemberListPathParams { - multicastGroup: NameOrId + multicastGroup: MulticastGroupIdentifier } export interface MulticastGroupMemberListQueryParams { @@ -6197,23 +6211,6 @@ export interface MulticastGroupMemberListQueryParams { sortBy?: IdSortMode } -export interface MulticastGroupMemberAddPathParams { - multicastGroup: NameOrId -} - -export interface MulticastGroupMemberAddQueryParams { - project?: NameOrId -} - -export interface MulticastGroupMemberRemovePathParams { - instance: NameOrId - multicastGroup: NameOrId -} - -export interface MulticastGroupMemberRemoveQueryParams { - project?: NameOrId -} - export interface InstanceNetworkInterfaceListQueryParams { instance?: NameOrId limit?: number | null @@ -6568,10 +6565,6 @@ export interface SystemMetricQueryParams { silo?: NameOrId } -export interface LookupMulticastGroupByIpPathParams { - address: string -} - export interface NetworkingAddressLotListQueryParams { limit?: number | null pageToken?: string | null @@ -7050,7 +7043,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026010500.0.0' + apiVersion = '2026011600.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -7199,7 +7192,7 @@ export class Api { }) }, /** - * View a support bundle + * View support bundle */ supportBundleView: ( { path }: { path: SupportBundleViewPathParams }, @@ -7882,7 +7875,7 @@ export class Api { }) }, /** - * Create a disk + * Create disk */ diskCreate: ( { query, body }: { query: DiskCreateQueryParams; body: DiskCreate }, @@ -8501,7 +8494,7 @@ export class Api { }) }, /** - * List multicast groups for instance + * List multicast groups for an instance */ instanceMulticastGroupList: ( { @@ -8521,27 +8514,30 @@ export class Api { }) }, /** - * Join multicast group. + * Join multicast group by name, IP address, or UUID */ instanceMulticastGroupJoin: ( { path, query = {}, + body, }: { path: InstanceMulticastGroupJoinPathParams query?: InstanceMulticastGroupJoinQueryParams + body: InstanceMulticastGroupJoin }, params: FetchParams = {} ) => { return this.request({ path: `/v1/instances/${path.instance}/multicast-groups/${path.multicastGroup}`, method: 'PUT', + body, query, ...params, }) }, /** - * Leave multicast group. + * Leave multicast group by name, IP address, or UUID */ instanceMulticastGroupLeave: ( { @@ -8561,7 +8557,7 @@ export class Api { }) }, /** - * Reboot an instance + * Reboot instance */ instanceReboot: ( { @@ -9001,7 +8997,7 @@ export class Api { }) }, /** - * List all multicast groups. + * List multicast groups */ multicastGroupList: ( { query = {} }: { query?: MulticastGroupListQueryParams }, @@ -9015,21 +9011,7 @@ export class Api { }) }, /** - * Create a multicast group. - */ - multicastGroupCreate: ( - { body }: { body: MulticastGroupCreate }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups`, - method: 'POST', - body, - ...params, - }) - }, - /** - * Fetch a multicast group. + * Fetch multicast group */ multicastGroupView: ( { path }: { path: MulticastGroupViewPathParams }, @@ -9042,34 +9024,7 @@ export class Api { }) }, /** - * Update a multicast group. - */ - multicastGroupUpdate: ( - { path, body }: { path: MulticastGroupUpdatePathParams; body: MulticastGroupUpdate }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}`, - method: 'PUT', - body, - ...params, - }) - }, - /** - * Delete a multicast group. - */ - multicastGroupDelete: ( - { path }: { path: MulticastGroupDeletePathParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}`, - method: 'DELETE', - ...params, - }) - }, - /** - * List members of a multicast group. + * List members of multicast group */ multicastGroupMemberList: ( { @@ -9088,49 +9043,6 @@ export class Api { ...params, }) }, - /** - * Add instance to a multicast group. - */ - multicastGroupMemberAdd: ( - { - path, - query = {}, - body, - }: { - path: MulticastGroupMemberAddPathParams - query?: MulticastGroupMemberAddQueryParams - body: MulticastGroupMemberAdd - }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}/members`, - method: 'POST', - body, - query, - ...params, - }) - }, - /** - * Remove instance from a multicast group. - */ - multicastGroupMemberRemove: ( - { - path, - query = {}, - }: { - path: MulticastGroupMemberRemovePathParams - query?: MulticastGroupMemberRemoveQueryParams - }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/multicast-groups/${path.multicastGroup}/members/${path.instance}`, - method: 'DELETE', - query, - ...params, - }) - }, /** * List network interfaces */ @@ -9296,7 +9208,7 @@ export class Api { }) }, /** - * Update a project + * Update project */ projectUpdate: ( { path, body }: { path: ProjectUpdatePathParams; body: ProjectUpdate }, @@ -9441,7 +9353,7 @@ export class Api { }) }, /** - * Get a physical disk + * Get physical disk */ physicalDiskView: ( { path }: { path: PhysicalDiskViewPathParams }, @@ -9928,7 +9840,7 @@ export class Api { }) }, /** - * Add range to IP pool. + * Add range to an IP pool */ ipPoolRangeAdd: ( { path, body }: { path: IpPoolRangeAddPathParams; body: IpRange }, @@ -10089,19 +10001,6 @@ export class Api { ...params, }) }, - /** - * Look up multicast group by IP address. - */ - lookupMulticastGroupByIp: ( - { path }: { path: LookupMulticastGroupByIpPathParams }, - params: FetchParams = {} - ) => { - return this.request({ - path: `/v1/system/multicast-groups/by-ip/${path.address}`, - method: 'GET', - ...params, - }) - }, /** * List address lots */ @@ -10201,7 +10100,7 @@ export class Api { }) }, /** - * Disable a BFD session + * Disable BFD session */ networkingBfdDisable: ( { body }: { body: BfdSessionDisable }, @@ -10215,7 +10114,7 @@ export class Api { }) }, /** - * Enable a BFD session + * Enable BFD session */ networkingBfdEnable: ( { body }: { body: BfdSessionEnable }, @@ -10611,7 +10510,7 @@ export class Api { }) }, /** - * Create a silo + * Create silo */ siloCreate: ({ body }: { body: SiloCreate }, params: FetchParams = {}) => { return this.request({ @@ -10632,7 +10531,7 @@ export class Api { }) }, /** - * Delete a silo + * Delete silo */ siloDelete: ({ path }: { path: SiloDeletePathParams }, params: FetchParams = {}) => { return this.request({ @@ -11381,7 +11280,7 @@ export class Api { }) }, /** - * Update a VPC + * Update VPC */ vpcUpdate: ( { diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index cc761a204..26a3df73c 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -135be59591f1ba4bc5941f63bf3a08a0b187b1a9 +26b33a11962d82b29fde9a2e3233d038d5495c44 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 3d0aa5ace..a29025911 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -639,6 +639,7 @@ export interface MSWHandlers { instanceMulticastGroupJoin: (params: { path: Api.InstanceMulticastGroupJoinPathParams query: Api.InstanceMulticastGroupJoinQueryParams + body: Json req: Request cookies: Record }) => Promisable> @@ -842,31 +843,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups` */ - multicastGroupCreate: (params: { - body: Json - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/multicast-groups/:multicastGroup` */ multicastGroupView: (params: { path: Api.MulticastGroupViewPathParams req: Request cookies: Record }) => Promisable> - /** `PUT /v1/multicast-groups/:multicastGroup` */ - multicastGroupUpdate: (params: { - path: Api.MulticastGroupUpdatePathParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup` */ - multicastGroupDelete: (params: { - path: Api.MulticastGroupDeletePathParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/multicast-groups/:multicastGroup/members` */ multicastGroupMemberList: (params: { path: Api.MulticastGroupMemberListPathParams @@ -874,21 +856,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `POST /v1/multicast-groups/:multicastGroup/members` */ - multicastGroupMemberAdd: (params: { - path: Api.MulticastGroupMemberAddPathParams - query: Api.MulticastGroupMemberAddQueryParams - body: Json - req: Request - cookies: Record - }) => Promisable> - /** `DELETE /v1/multicast-groups/:multicastGroup/members/:instance` */ - multicastGroupMemberRemove: (params: { - path: Api.MulticastGroupMemberRemovePathParams - query: Api.MulticastGroupMemberRemoveQueryParams - req: Request - cookies: Record - }) => Promisable /** `GET /v1/network-interfaces` */ instanceNetworkInterfaceList: (params: { query: Api.InstanceNetworkInterfaceListQueryParams @@ -1305,12 +1272,6 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> - /** `GET /v1/system/multicast-groups/by-ip/:address` */ - lookupMulticastGroupByIp: (params: { - path: Api.LookupMulticastGroupByIpPathParams - req: Request - cookies: Record - }) => Promisable> /** `GET /v1/system/networking/address-lot` */ networkingAddressLotList: (params: { query: Api.NetworkingAddressLotListQueryParams @@ -2498,7 +2459,7 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { handler( handlers['instanceMulticastGroupJoin'], schema.InstanceMulticastGroupJoinParams, - null + schema.InstanceMulticastGroupJoin ) ), http.delete( @@ -2675,26 +2636,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/multicast-groups', handler(handlers['multicastGroupList'], schema.MulticastGroupListParams, null) ), - http.post( - '/v1/multicast-groups', - handler(handlers['multicastGroupCreate'], null, schema.MulticastGroupCreate) - ), http.get( '/v1/multicast-groups/:multicastGroup', handler(handlers['multicastGroupView'], schema.MulticastGroupViewParams, null) ), - http.put( - '/v1/multicast-groups/:multicastGroup', - handler( - handlers['multicastGroupUpdate'], - schema.MulticastGroupUpdateParams, - schema.MulticastGroupUpdate - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup', - handler(handlers['multicastGroupDelete'], schema.MulticastGroupDeleteParams, null) - ), http.get( '/v1/multicast-groups/:multicastGroup/members', handler( @@ -2703,22 +2648,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { null ) ), - http.post( - '/v1/multicast-groups/:multicastGroup/members', - handler( - handlers['multicastGroupMemberAdd'], - schema.MulticastGroupMemberAddParams, - schema.MulticastGroupMemberAdd - ) - ), - http.delete( - '/v1/multicast-groups/:multicastGroup/members/:instance', - handler( - handlers['multicastGroupMemberRemove'], - schema.MulticastGroupMemberRemoveParams, - null - ) - ), http.get( '/v1/network-interfaces', handler( @@ -3054,14 +2983,6 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/metrics/:metricName', handler(handlers['systemMetric'], schema.SystemMetricParams, null) ), - http.get( - '/v1/system/multicast-groups/by-ip/:address', - handler( - handlers['lookupMulticastGroupByIp'], - schema.LookupMulticastGroupByIpParams, - null - ) - ), http.get( '/v1/system/networking/address-lot', handler( diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 596067ef0..672c41e85 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -85,6 +85,40 @@ export const Address = z.preprocess( }) ) +/** + * The IP address version. + */ +export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) + +/** + * Specify which IP pool to allocate from. + */ +export const PoolSelector = z.preprocess( + processResponseBody, + z.union([ + z.object({ pool: NameOrId, type: z.enum(['explicit']) }), + z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), + ]) +) + +/** + * Specify how to allocate a floating IP address. + */ +export const AddressAllocator = z.preprocess( + processResponseBody, + z.union([ + z.object({ + ip: z.ipv4(), + pool: NameOrId.nullable().optional(), + type: z.enum(['explicit']), + }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), + type: z.enum(['auto']), + }), + ]) +) + /** * A set of addresses associated with a port configuration. */ @@ -174,40 +208,6 @@ export const AddressLotViewResponse = z.preprocess( z.object({ blocks: AddressLotBlock.array(), lot: AddressLot }) ) -/** - * The IP address version. - */ -export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) - -/** - * Specify which IP pool to allocate from. - */ -export const PoolSelector = z.preprocess( - processResponseBody, - z.union([ - z.object({ pool: NameOrId, type: z.enum(['explicit']) }), - z.object({ ipVersion: IpVersion.nullable().default(null), type: z.enum(['auto']) }), - ]) -) - -/** - * Specify how to allocate a floating IP address. - */ -export const AddressSelector = z.preprocess( - processResponseBody, - z.union([ - z.object({ - ip: z.ipv4(), - pool: NameOrId.nullable().optional(), - type: z.enum(['explicit']), - }), - z.object({ - poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), - type: z.enum(['auto']), - }), - ]) -) - /** * Describes the scope of affinity for the purposes of co-location. */ @@ -638,6 +638,14 @@ export const AuditLogEntryActor = z.preprocess( ]) ) +/** + * Authentication method used for a request + */ +export const AuthMethod = z.preprocess( + processResponseBody, + z.enum(['session_cookie', 'access_token', 'scim_token']) +) + /** * Result of an audit log entry */ @@ -662,7 +670,8 @@ export const AuditLogEntry = z.preprocess( processResponseBody, z.object({ actor: AuditLogEntryActor, - authMethod: z.string().nullable().optional(), + authMethod: AuthMethod.nullable().optional(), + credentialId: z.uuid().nullable().optional(), id: z.uuid(), operationId: z.string(), requestId: z.string(), @@ -2009,7 +2018,7 @@ export const FloatingIpAttach = z.preprocess( export const FloatingIpCreate = z.preprocess( processResponseBody, z.object({ - addressSelector: AddressSelector.default({ + addressAllocator: AddressAllocator.default({ poolSelector: { ipVersion: null, type: 'auto' }, type: 'auto', }), @@ -2251,6 +2260,27 @@ export const InstanceDiskAttachment = z.preprocess( ]) ) +/** + * A multicast group identifier + * + * Can be a UUID, a name, or an IP address + */ +export const MulticastGroupIdentifier = z.preprocess(processResponseBody, z.string()) + +/** + * Specification for joining a multicast group with optional source filtering. + * + * Used in `InstanceCreate` and `InstanceUpdate` to specify multicast group membership along with per-member source IP configuration. + */ +export const MulticastGroupJoinSpec = z.preprocess( + processResponseBody, + z.object({ + group: MulticastGroupIdentifier, + ipVersion: IpVersion.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), + }) +) + /** * How a VPC-private IP address is assigned to a network interface. */ @@ -2353,7 +2383,7 @@ export const InstanceCreate = z.preprocess( externalIps: ExternalIpCreate.array().default([]), hostname: Hostname, memory: ByteCount, - multicastGroups: NameOrId.array().default([]), + multicastGroups: MulticastGroupJoinSpec.array().default([]), name: Name, ncpus: InstanceCpuCount, networkInterfaces: InstanceNetworkInterfaceAttachment.default({ @@ -2365,6 +2395,19 @@ export const InstanceCreate = z.preprocess( }) ) +/** + * Parameters for joining an instance to a multicast group. + * + * When joining by IP address, the pool containing the multicast IP is auto-discovered from all linked multicast pools. + */ +export const InstanceMulticastGroupJoin = z.preprocess( + processResponseBody, + z.object({ + ipVersion: IpVersion.nullable().default(null), + sourceIps: z.ipv4().array().nullable().default(null), + }) +) + /** * The VPC-private IPv4 stack for a network interface */ @@ -2482,7 +2525,7 @@ export const InstanceUpdate = z.preprocess( bootDisk: NameOrId.nullable(), cpuPlatform: InstanceCpuPlatform.nullable(), memory: ByteCount, - multicastGroups: NameOrId.array().nullable().default(null), + multicastGroups: MulticastGroupJoinSpec.array().nullable().default(null), ncpus: InstanceCpuCount, }) ) @@ -2956,21 +2999,6 @@ export const MulticastGroup = z.preprocess( }) ) -/** - * Create-time parameters for a multicast group. - */ -export const MulticastGroupCreate = z.preprocess( - processResponseBody, - z.object({ - description: z.string(), - multicastIp: z.ipv4().nullable().default(null), - mvlan: z.number().min(0).max(65535).nullable().default(null), - name: Name, - pool: NameOrId.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), - }) -) - /** * View of a Multicast Group Member (instance belonging to a multicast group) */ @@ -2981,21 +3009,15 @@ export const MulticastGroupMember = z.preprocess( id: z.uuid(), instanceId: z.uuid(), multicastGroupId: z.uuid(), + multicastIp: z.ipv4(), name: Name, + sourceIps: z.ipv4().array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), }) ) -/** - * Parameters for adding an instance to a multicast group. - */ -export const MulticastGroupMemberAdd = z.preprocess( - processResponseBody, - z.object({ instance: NameOrId }) -) - /** * A single page of results */ @@ -3015,19 +3037,6 @@ export const MulticastGroupResultsPage = z.preprocess( z.object({ items: MulticastGroup.array(), nextPage: z.string().nullable().optional() }) ) -/** - * Update-time parameters for a multicast group. - */ -export const MulticastGroupUpdate = z.preprocess( - processResponseBody, - z.object({ - description: z.string().nullable().optional(), - mvlan: z.number().min(0).max(65535).nullable().optional(), - name: Name.nullable().optional(), - sourceIps: z.ipv4().array().nullable().optional(), - }) -) - /** * VPC-private IPv4 configuration for a network interface. */ @@ -5912,7 +5921,10 @@ export const InstanceMulticastGroupListParams = z.preprocess( instance: NameOrId, }), query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), project: NameOrId.optional(), + sortBy: IdSortMode.optional(), }), }) ) @@ -5922,7 +5934,7 @@ export const InstanceMulticastGroupJoinParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -5935,7 +5947,7 @@ export const InstanceMulticastGroupLeaveParams = z.preprocess( z.object({ path: z.object({ instance: NameOrId, - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ project: NameOrId.optional(), @@ -6309,39 +6321,11 @@ export const MulticastGroupListParams = z.preprocess( }) ) -export const MulticastGroupCreateParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({}), - query: z.object({}), - }) -) - export const MulticastGroupViewParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({}), - }) -) - -export const MulticastGroupUpdateParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({}), - }) -) - -export const MulticastGroupDeleteParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({}), }) @@ -6351,7 +6335,7 @@ export const MulticastGroupMemberListParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - multicastGroup: NameOrId, + multicastGroup: MulticastGroupIdentifier, }), query: z.object({ limit: z.number().min(1).max(4294967295).nullable().optional(), @@ -6361,31 +6345,6 @@ export const MulticastGroupMemberListParams = z.preprocess( }) ) -export const MulticastGroupMemberAddParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - multicastGroup: NameOrId, - }), - query: z.object({ - project: NameOrId.optional(), - }), - }) -) - -export const MulticastGroupMemberRemoveParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - instance: NameOrId, - multicastGroup: NameOrId, - }), - query: z.object({ - project: NameOrId.optional(), - }), - }) -) - export const InstanceNetworkInterfaceListParams = z.preprocess( processResponseBody, z.object({ @@ -7104,16 +7063,6 @@ export const SystemMetricParams = z.preprocess( }) ) -export const LookupMulticastGroupByIpParams = z.preprocess( - processResponseBody, - z.object({ - path: z.object({ - address: z.ipv4(), - }), - query: z.object({}), - }) -) - export const NetworkingAddressLotListParams = z.preprocess( processResponseBody, z.object({ diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 9ff4dae77..7e5366334 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -73,7 +73,7 @@ export default function CreateFloatingIpSideModalForm() { onSubmit={({ pool, ...values }) => { const body: FloatingIpCreate = { ...values, - addressSelector: pool + addressAllocator: pool ? { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 71def2c2b..5b870b3be 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -306,14 +306,14 @@ export const handlers = makeHandlers({ errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool - const addressSelector = body.address_selector || { type: 'auto' } + const addressAllocator = body.address_allocator || { type: 'auto' } const pool = - addressSelector.type === 'explicit' && addressSelector.pool - ? lookup.siloIpPool({ pool: addressSelector.pool, silo: defaultSilo.id }) - : addressSelector.type === 'auto' && - addressSelector.pool_selector?.type === 'explicit' + addressAllocator.type === 'explicit' && addressAllocator.pool + ? lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) + : addressAllocator.type === 'auto' && + addressAllocator.pool_selector?.type === 'explicit' ? lookup.siloIpPool({ - pool: addressSelector.pool_selector.pool, + pool: addressAllocator.pool_selector.pool, silo: defaultSilo.id, }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) @@ -323,7 +323,7 @@ export const handlers = makeHandlers({ project_id: project.id, // TODO: use ip-num to actually get the next available IP in the pool ip: - (addressSelector.type === 'explicit' && addressSelector.ip) || + (addressAllocator.type === 'explicit' && addressAllocator.ip) || Array.from({ length: 4 }) .map(() => Math.floor(Math.random() * 256)) .join('.'), @@ -2102,14 +2102,8 @@ export const handlers = makeHandlers({ localIdpUserDelete: NotImplemented, localIdpUserSetPassword: NotImplemented, loginSaml: NotImplemented, - lookupMulticastGroupByIp: NotImplemented, - multicastGroupCreate: NotImplemented, - multicastGroupDelete: NotImplemented, multicastGroupList: NotImplemented, - multicastGroupMemberAdd: NotImplemented, multicastGroupMemberList: NotImplemented, - multicastGroupMemberRemove: NotImplemented, - multicastGroupUpdate: NotImplemented, multicastGroupView: NotImplemented, networkingAddressLotBlockList: NotImplemented, networkingAddressLotCreate: NotImplemented, From e9e82f976d31498620c10cad239ae0d71def1ded Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 13:05:10 -0800 Subject: [PATCH 14/73] Bump @oxide/openapi-gen-ts --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5c6802547..8c68ce424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.13.1", + "@oxide/openapi-gen-ts": "~0.14.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -1669,9 +1669,9 @@ } }, "node_modules/@oxide/openapi-gen-ts": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.13.1.tgz", - "integrity": "sha512-ZVT4vfHrEniWKwwhWEjkaZfbxzjZGPaTjwb4u+I8P5qaT2YkT/D6Y7W/SH4nAOKOSl1PZUIDgfjVQ9rDh8CYKA==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@oxide/openapi-gen-ts/-/openapi-gen-ts-0.14.0.tgz", + "integrity": "sha512-L7n/3Ox8UTgDwdDvqCr+PekXcTboq5HQhdEawZWD8ct9QkycCciZoClLWApz/B5T9eiQjZq/5nqyE5JJqqw6nw==", "dev": true, "license": "MPL-2.0", "dependencies": { diff --git a/package.json b/package.json index 0ebbfddda..114c8cd6b 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "devDependencies": { "@ianvs/prettier-plugin-sort-imports": "^4.7.0", "@mswjs/http-middleware": "^0.10.3", - "@oxide/openapi-gen-ts": "~0.13.1", + "@oxide/openapi-gen-ts": "~0.14.0", "@playwright/test": "^1.56.1", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", From 114baee2f10c37bace78d399c1f5561978aaf6a0 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 20 Jan 2026 13:06:14 -0800 Subject: [PATCH 15/73] npm run gen-api --- app/api/__generated__/validate.ts | 83 +++++++++++++++++-------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 672c41e85..08eb03a9c 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -108,7 +108,7 @@ export const AddressAllocator = z.preprocess( processResponseBody, z.union([ z.object({ - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), pool: NameOrId.nullable().optional(), type: z.enum(['explicit']), }), @@ -152,7 +152,11 @@ export const AddressLot = z.preprocess( */ export const AddressLotBlock = z.preprocess( processResponseBody, - z.object({ firstAddress: z.ipv4(), id: z.uuid(), lastAddress: z.ipv4() }) + z.object({ + firstAddress: z.union([z.ipv4(), z.ipv6()]), + id: z.uuid(), + lastAddress: z.union([z.ipv4(), z.ipv6()]), + }) ) /** @@ -160,7 +164,10 @@ export const AddressLotBlock = z.preprocess( */ export const AddressLotBlockCreate = z.preprocess( processResponseBody, - z.object({ firstAddress: z.ipv4(), lastAddress: z.ipv4() }) + z.object({ + firstAddress: z.union([z.ipv4(), z.ipv6()]), + lastAddress: z.union([z.ipv4(), z.ipv6()]), + }) ) /** @@ -677,7 +684,7 @@ export const AuditLogEntry = z.preprocess( requestId: z.string(), requestUri: z.string(), result: AuditLogEntryResult, - sourceIp: z.ipv4(), + sourceIp: z.union([z.ipv4(), z.ipv6()]), timeCompleted: z.coerce.date(), timeStarted: z.coerce.date(), userAgent: z.string().nullable().optional(), @@ -727,7 +734,7 @@ export const BfdMode = z.preprocess( */ export const BfdSessionDisable = z.preprocess( processResponseBody, - z.object({ remote: z.ipv4(), switch: Name }) + z.object({ remote: z.union([z.ipv4(), z.ipv6()]), switch: Name }) ) /** @@ -737,9 +744,9 @@ export const BfdSessionEnable = z.preprocess( processResponseBody, z.object({ detectionThreshold: z.number().min(0).max(255), - local: z.ipv4().nullable().optional(), + local: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), mode: BfdMode, - remote: z.ipv4(), + remote: z.union([z.ipv4(), z.ipv6()]), requiredRx: z.number().min(0), switch: Name, }) @@ -754,9 +761,9 @@ export const BfdStatus = z.preprocess( processResponseBody, z.object({ detectionThreshold: z.number().min(0).max(255), - local: z.ipv4().nullable().optional(), + local: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), mode: BfdMode, - peer: z.ipv4(), + peer: z.union([z.ipv4(), z.ipv6()]), requiredRx: z.number().min(0), state: BfdState, switch: Name, @@ -881,7 +888,7 @@ export const ImportExportPolicy = z.preprocess( export const BgpPeer = z.preprocess( processResponseBody, z.object({ - addr: z.ipv4(), + addr: z.union([z.ipv4(), z.ipv6()]), allowedExport: ImportExportPolicy, allowedImport: ImportExportPolicy, bgpConfig: NameOrId, @@ -930,7 +937,7 @@ export const BgpPeerState = z.preprocess( export const BgpPeerStatus = z.preprocess( processResponseBody, z.object({ - addr: z.ipv4(), + addr: z.union([z.ipv4(), z.ipv6()]), localAsn: z.number().min(0).max(4294967295), remoteAsn: z.number().min(0).max(4294967295), state: BgpPeerState, @@ -1837,17 +1844,21 @@ export const ExternalIp = z.preprocess( z.union([ z.object({ firstPort: z.number().min(0).max(65535), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), kind: z.enum(['snat']), lastPort: z.number().min(0).max(65535), }), - z.object({ ip: z.ipv4(), ipPoolId: z.uuid(), kind: z.enum(['ephemeral']) }), + z.object({ + ip: z.union([z.ipv4(), z.ipv6()]), + ipPoolId: z.uuid(), + kind: z.enum(['ephemeral']), + }), z.object({ description: z.string(), id: z.uuid(), instanceId: z.uuid().nullable().optional(), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), kind: z.enum(['floating']), name: Name, @@ -1934,7 +1945,7 @@ export const FieldValue = z.preprocess( z.object({ type: z.enum(['u32']), value: z.number().min(0).max(4294967295) }), z.object({ type: z.enum(['i64']), value: z.number() }), z.object({ type: z.enum(['u64']), value: z.number().min(0) }), - z.object({ type: z.enum(['ip_addr']), value: z.ipv4() }), + z.object({ type: z.enum(['ip_addr']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['uuid']), value: z.uuid() }), z.object({ type: z.enum(['bool']), value: SafeBoolean }), ]) @@ -1990,7 +2001,7 @@ export const FloatingIp = z.preprocess( description: z.string(), id: z.uuid(), instanceId: z.uuid().nullable().optional(), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), ipPoolId: z.uuid(), name: Name, projectId: z.uuid(), @@ -2277,7 +2288,7 @@ export const MulticastGroupJoinSpec = z.preprocess( z.object({ group: MulticastGroupIdentifier, ipVersion: IpVersion.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), }) ) @@ -2404,7 +2415,7 @@ export const InstanceMulticastGroupJoin = z.preprocess( processResponseBody, z.object({ ipVersion: IpVersion.nullable().default(null), - sourceIps: z.ipv4().array().nullable().default(null), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array().nullable().default(null), }) ) @@ -2568,7 +2579,7 @@ export const InternetGatewayCreate = z.preprocess( export const InternetGatewayIpAddress = z.preprocess( processResponseBody, z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), description: z.string(), id: z.uuid(), internetGatewayId: z.uuid(), @@ -2583,7 +2594,7 @@ export const InternetGatewayIpAddress = z.preprocess( */ export const InternetGatewayIpAddressCreate = z.preprocess( processResponseBody, - z.object({ address: z.ipv4(), description: z.string(), name: Name }) + z.object({ address: z.union([z.ipv4(), z.ipv6()]), description: z.string(), name: Name }) ) /** @@ -2805,7 +2816,7 @@ export const LldpLinkConfigCreate = z.preprocess( enabled: SafeBoolean, linkDescription: z.string().nullable().optional(), linkName: z.string().nullable().optional(), - managementIp: z.ipv4().nullable().optional(), + managementIp: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), systemDescription: z.string().nullable().optional(), systemName: z.string().nullable().optional(), }) @@ -2870,7 +2881,7 @@ export const LldpLinkConfig = z.preprocess( id: z.uuid(), linkDescription: z.string().nullable().optional(), linkName: z.string().nullable().optional(), - managementIp: z.ipv4().nullable().optional(), + managementIp: z.union([z.ipv4(), z.ipv6()]).nullable().optional(), systemDescription: z.string().nullable().optional(), systemName: z.string().nullable().optional(), }) @@ -2879,7 +2890,7 @@ export const LldpLinkConfig = z.preprocess( export const NetworkAddress = z.preprocess( processResponseBody, z.union([ - z.object({ ipAddr: z.ipv4() }), + z.object({ ipAddr: z.union([z.ipv4(), z.ipv6()]) }), z.object({ iEEE802: z.number().min(0).max(255).array() }), ]) ) @@ -2939,7 +2950,7 @@ export const LoopbackAddress = z.preprocess( export const LoopbackAddressCreate = z.preprocess( processResponseBody, z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), addressLot: NameOrId, anycast: SafeBoolean, mask: z.number().min(0).max(255), @@ -2989,10 +3000,10 @@ export const MulticastGroup = z.preprocess( description: z.string(), id: z.uuid(), ipPoolId: z.uuid(), - multicastIp: z.ipv4(), + multicastIp: z.union([z.ipv4(), z.ipv6()]), mvlan: z.number().min(0).max(65535).nullable().optional(), name: Name, - sourceIps: z.ipv4().array(), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), @@ -3009,9 +3020,9 @@ export const MulticastGroupMember = z.preprocess( id: z.uuid(), instanceId: z.uuid(), multicastGroupId: z.uuid(), - multicastIp: z.ipv4(), + multicastIp: z.union([z.ipv4(), z.ipv6()]), name: Name, - sourceIps: z.ipv4().array(), + sourceIps: z.union([z.ipv4(), z.ipv6()]).array(), state: z.string(), timeCreated: z.coerce.date(), timeModified: z.coerce.date(), @@ -3274,7 +3285,7 @@ export const ProbeExternalIp = z.preprocess( processResponseBody, z.object({ firstPort: z.number().min(0).max(65535), - ip: z.ipv4(), + ip: z.union([z.ipv4(), z.ipv6()]), kind: ProbeExternalIpKind, lastPort: z.number().min(0).max(65535), }) @@ -3388,7 +3399,7 @@ export const Route = z.preprocess( processResponseBody, z.object({ dst: IpNet, - gw: z.ipv4(), + gw: z.union([z.ipv4(), z.ipv6()]), ribPriority: z.number().min(0).max(255).nullable().optional(), vid: z.number().min(0).max(65535).nullable().optional(), }) @@ -3410,7 +3421,7 @@ export const RouteConfig = z.preprocess( export const RouteDestination = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), @@ -3423,7 +3434,7 @@ export const RouteDestination = z.preprocess( export const RouteTarget = z.preprocess( processResponseBody, z.union([ - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), @@ -4152,7 +4163,7 @@ export const SwitchPortRouteConfig = z.preprocess( processResponseBody, z.object({ dst: IpNet, - gw: z.ipv4(), + gw: z.union([z.ipv4(), z.ipv6()]), interfaceName: Name, portSettingsId: z.uuid(), ribPriority: z.number().min(0).max(255).nullable().optional(), @@ -4581,7 +4592,7 @@ export const VpcFirewallRuleHostFilter = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -4624,7 +4635,7 @@ export const VpcFirewallRuleTarget = z.preprocess( z.object({ type: z.enum(['vpc']), value: Name }), z.object({ type: z.enum(['subnet']), value: Name }), z.object({ type: z.enum(['instance']), value: Name }), - z.object({ type: z.enum(['ip']), value: z.ipv4() }), + z.object({ type: z.enum(['ip']), value: z.union([z.ipv4(), z.ipv6()]) }), z.object({ type: z.enum(['ip_net']), value: IpNet }), ]) ) @@ -7303,7 +7314,7 @@ export const NetworkingLoopbackAddressDeleteParams = z.preprocess( processResponseBody, z.object({ path: z.object({ - address: z.ipv4(), + address: z.union([z.ipv4(), z.ipv6()]), rackId: z.uuid(), subnetMask: z.number().min(0).max(255), switchLocation: Name, From 0e2ea44955048a46726fe7e4e2302ff8fc38f953 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 14:40:06 -0800 Subject: [PATCH 16/73] Update UX for ephemeral IP attach modal --- app/components/AttachEphemeralIpModal.tsx | 20 +++++--------------- test/e2e/instance-networking.e2e.ts | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 24f8aec03..e4c3c69d5 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,7 +6,6 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' import { useForm } from 'react-hook-form' import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' @@ -24,10 +23,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => siloPools?.items.find((pool) => pool.isDefault), - [siloPools] - ) const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -39,7 +34,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const form = useForm({ defaultValues: { pool: defaultPool?.name } }) + const form = useForm({ defaultValues: { pool: '' } }) const pool = form.watch('pool') return ( @@ -51,26 +46,21 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) control={form.control} name="pool" label="IP pool" - placeholder={ - siloPools?.items && siloPools.items.length > 0 - ? 'Select a pool' - : 'No pools available' - } + placeholder="Default pool" items={siloPools.items.map(toIpPoolItem)} - required /> { - if (!pool) return instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, - body: { poolSelector: { type: 'explicit', pool } }, + body: pool + ? { poolSelector: { type: 'explicit', pool } } + : { poolSelector: { type: 'auto' } }, }) }} onDismiss={onDismiss} diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 3079160e9..7f41656e9 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -122,9 +122,25 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page // The 'Attach ephemeral IP' button should be visible and enabled now that the existing ephemeral IP has been detached await expect(attachEphemeralIpButton).toBeEnabled() - // Attach a new ephemeral IP + // Attach a new ephemeral IP using the default pool (don't select a pool) await attachEphemeralIpButton.click() - const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + let modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + // Click Attach without selecting a pool - should use default pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + await expect(modal).toBeHidden() + await expect(ephemeralCell).toBeVisible() + + // The 'Attach ephemeral IP' button should be hidden after attaching an ephemeral IP + await expect(attachEphemeralIpButton).toBeHidden() + + // Detach and test with explicit pool selection + await clickRowAction(page, 'ephemeral', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(ephemeralCell).toBeHidden() + + await attachEphemeralIpButton.click() + modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() await page.getByRole('button', { name: 'IP pool' }).click() await page.getByRole('option', { name: 'ip-pool-2' }).click() From 807c927b259f5ff352681ca5f1107196d925bd4a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:22:13 -0800 Subject: [PATCH 17/73] e2e text flexibility --- test/e2e/network-interface-create.e2e.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index b68f427b4..87308afee 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -76,7 +76,10 @@ test('can create a NIC with a blank IP address', async ({ page }) => { // ip address is auto-assigned (dual-stack by default) const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-2', 'Private IP': '123.45.68.8fd12:3456::' }) + await expectRowVisible(table, { + name: 'nic-2', + 'Private IP': expect.stringMatching(/123\.45\.68\.8\s*fd12:3456::/), + }) }) test('can create a NIC with IPv6 only', async ({ page }) => { @@ -126,5 +129,8 @@ test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { await expect(sidebar).toBeHidden() const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-4', 'Private IP': '10.0.0.5fd00::5' }) + await expectRowVisible(table, { + name: 'nic-4', + 'Private IP': expect.stringMatching(/10\.0\.0\.5\s*fd00::5/), + }) }) From abc336f4396bf72fa749f10d203d5be167521f66 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:50:17 -0800 Subject: [PATCH 18/73] fix bug when defaultPool was falsy --- app/forms/instance-create.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e594a8dc7..8b6891523 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -234,7 +234,7 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] - : [], + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) @@ -648,7 +648,10 @@ const AdvancedAccordion = ({ const externalIps = useController({ control, name: 'externalIps' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp - const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined + const selectedPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined const defaultPool = siloPools.find((pool) => pool.isDefault)?.name const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) @@ -743,7 +746,15 @@ const AdvancedAccordion = ({ ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') : [ ...(externalIps.field.value || []), - { type: 'ephemeral', pool: selectedPool || defaultPool }, + selectedPool || defaultPool + ? { + type: 'ephemeral', + poolSelector: { + type: 'explicit', + pool: selectedPool || defaultPool, + }, + } + : { type: 'ephemeral' }, ] externalIps.field.onChange(newExternalIps) }} @@ -761,7 +772,9 @@ const AdvancedAccordion = ({ required onChange={(value) => { const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' ? { ...ip, pool: value } : ip + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + : ip ) externalIps.field.onChange(newExternalIps) }} From e887c747993aba88bf64d3e50125c23ca470c616 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 15:52:32 -0800 Subject: [PATCH 19/73] fix runtime issue if siloPools haven't loaded --- app/components/AttachEphemeralIpModal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index e4c3c69d5..c568f9c0e 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -47,13 +47,14 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) name="pool" label="IP pool" placeholder="Default pool" - items={siloPools.items.map(toIpPoolItem)} + items={(siloPools?.items ?? []).map(toIpPoolItem)} /> { instanceEphemeralIpAttach.mutate({ path: { instance }, From 9d08a826f008a258355d412b9bc2a8e1147c4be5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 16:50:36 -0800 Subject: [PATCH 20/73] Fix bug where when both IPv4 and IPv6 default pools exist, { poolSelector: { type: 'auto' } } fails unless ipVersion is specified --- app/components/AttachEphemeralIpModal.tsx | 45 +++++++++++++++++++++-- app/forms/instance-create.tsx | 25 ++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index c568f9c0e..2a30ce5b7 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,9 +6,17 @@ * Copyright Oxide Computer Company */ +import { useMemo } from 'react' import { useForm } from 'react-hook-form' -import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '~/api' +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IpVersion, +} from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' @@ -23,6 +31,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + if (!siloPools) return false + const defaultUnicastPools = siloPools.items.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [siloPools]) + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -34,8 +54,12 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) - const form = useForm({ defaultValues: { pool: '' } }) + + const form = useForm<{ pool: string; ipVersion: IpVersion }>({ + defaultValues: { pool: '', ipVersion: 'v4' }, + }) const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') return ( @@ -49,6 +73,19 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) placeholder="Default pool" items={(siloPools?.items ?? []).map(toIpPoolItem)} /> + {!pool && hasDualDefaults && ( + + )} @@ -61,7 +98,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : { poolSelector: { type: 'auto' } }, + : hasDualDefaults + ? { poolSelector: { type: 'auto', ipVersion } } + : { poolSelector: { type: 'auto' } }, }) }} onDismiss={onDismiss} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 8b6891523..702991c74 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -227,6 +227,17 @@ export default function CreateInstanceForm() { const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + if (!siloPools) return false + const defaultUnicastPools = siloPools.items.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [siloPools]) + const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -234,7 +245,9 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] - : [{ type: 'ephemeral' }], + : hasDualDefaults + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) @@ -598,6 +611,7 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} siloPools={siloPools.items} + hasDualDefaults={hasDualDefaults} /> Create instance @@ -634,10 +648,12 @@ const AdvancedAccordion = ({ control, isSubmitting, siloPools, + hasDualDefaults, }: { control: Control isSubmitting: boolean siloPools: Array + hasDualDefaults: boolean }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -754,7 +770,12 @@ const AdvancedAccordion = ({ pool: selectedPool || defaultPool, }, } - : { type: 'ephemeral' }, + : hasDualDefaults + ? { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion: 'v4' }, + } + : { type: 'ephemeral' }, ] externalIps.field.onChange(newExternalIps) }} From da700ef4cc39a0629f5d8b72cc281121333398b4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 21 Jan 2026 17:35:01 -0800 Subject: [PATCH 21/73] Better handling of dual default pools --- app/pages/system/networking/IpPoolPage.tsx | 46 ++++---- app/pages/system/silos/SiloIpPoolsTab.tsx | 119 ++++++++++++++++----- app/table/cells/DefaultPoolCell.tsx | 10 +- mock-api/ip-pool.ts | 2 +- mock-api/msw/handlers.ts | 17 ++- test/e2e/instance-create.e2e.ts | 2 +- test/e2e/silos.e2e.ts | 28 ++--- 7 files changed, 152 insertions(+), 72 deletions(-) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 3e5286d66..93c3f270c 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -255,26 +255,6 @@ function SiloNameFromId({ value: siloId }: { value: string }) { } const silosColHelper = createColumnHelper() -const silosStaticCols = [ - silosColHelper.accessor('siloId', { - header: 'Silo', - cell: (info) => , - }), - silosColHelper.accessor('isDefault', { - header: () => { - return ( - - Pool is silo default - - IPs are allocated from the default pool when users ask for an IP without - specifying a pool - - - ) - }, - cell: (info) => , - }), -] function LinkedSilosTable() { const poolSelector = useIpPoolSelector() @@ -328,7 +308,31 @@ function LinkedSilosTable() { /> ) - const columns = useColsWithActions(silosStaticCols, makeActions) + const silosCols = useMemo( + () => [ + silosColHelper.accessor('siloId', { + header: 'Silo', + cell: (info) => , + }), + silosColHelper.accessor('isDefault', { + header: () => { + return ( + + Pool is silo default + + IPs are allocated from the default pool when users ask for an IP without + specifying a pool + + + ) + }, + cell: (info) => , + }), + ], + [] + ) + + const columns = useColsWithActions(silosCols, makeActions) const { table } = useQueryTable({ query: ipPoolSiloList(poolSelector), columns, diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 2226f3aee..3bec42f85 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -12,8 +12,16 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' +import { + api, + getListQFn, + queryClient, + useApiMutation, + type IpVersion, + type SiloIpPool, +} from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' @@ -46,15 +54,6 @@ const EmptyState = () => ( const colHelper = createColumnHelper() -const staticCols = [ - colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), - colHelper.accessor('description', Columns.description), - colHelper.accessor('isDefault', { - header: 'Default', - cell: (info) => , - }), -] - const allPoolsQuery = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) const allSiloPoolsQuery = (silo: string) => @@ -70,19 +69,85 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } +// Helper component that computes dual defaults from table data +function DefaultPoolCellWithContext({ + isDefault, + ipVersion, + allRows, +}: { + isDefault: boolean + ipVersion: IpVersion + allRows: SiloIpPool[] +}) { + // Compute dual defaults from current table data + const hasDualDefaults = useMemo(() => { + const defaultUnicastPools = allRows.filter( + (pool) => pool.isDefault && pool.poolType === 'unicast' + ) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [allRows]) + + return ( + + ) +} + export default function SiloIpPoolsTab() { const { silo } = useSiloSelector() const [showLinkModal, setShowLinkModal] = useState(false) - // Fetch all_ish, but there should only be a few anyway. Not prefetched - // because the prefetched one only gets 25 to match the query table. This req - // is better to do async because they can't click make default that fast - // anyway. - const { data: allPools } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + // Fetch all pools for the table and for computing dual defaults + const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = allPoolsData?.items - // used in change default confirm modal - const defaultPool = useMemo( - () => (allPools ? allPools.items.find((p) => p.isDefault)?.name : undefined), + // Define columns + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'IP Version', + cell: (info) => + info.getValue() === 'v4' ? ( + v4 + ) : ( + v6 + ), + }), + colHelper.accessor('poolType', { + header: 'Type', + cell: (info) => + info.getValue() === 'unicast' ? ( + Unicast + ) : ( + Multicast + ), + }), + colHelper.accessor('isDefault', { + header: 'Default', + cell: (info) => ( + r.original)} + /> + ), + }), + ], + [] + ) + + // used in change default confirm modal - find existing default for same version/type + const findDefaultForVersionType = useCallback( + (ipVersion: string, poolType: string) => + allPools?.find( + (p) => p.isDefault && p.ipVersion === ipVersion && p.poolType === poolType + )?.name, [allPools] ) @@ -125,18 +190,22 @@ export default function SiloIpPoolsTab() { actionType: 'danger', }) } else { - const modalContent = defaultPool ? ( + const existingDefault = findDefaultForVersionType(pool.ipVersion, pool.poolType) + const versionLabel = pool.ipVersion === 'v4' ? 'IPv4' : 'IPv6' + const typeLabel = pool.poolType === 'unicast' ? 'unicast' : 'multicast' + + const modalContent = existingDefault ? (

- Are you sure you want to change the default pool from {defaultPool}{' '} - to {pool.name}? + Are you sure you want to change the default {versionLabel} {typeLabel} pool + from {existingDefault} to {pool.name}?

) : (

- Are you sure you want to make {pool.name} the default pool for this - silo? + Are you sure you want to make {pool.name} the default{' '} + {versionLabel} {typeLabel} pool for this silo?

) - const verb = defaultPool ? 'change' : 'make' + const verb = existingDefault ? 'change' : 'make' confirmAction({ doAction: () => updatePoolLink({ @@ -171,7 +240,7 @@ export default function SiloIpPoolsTab() { }, }, ], - [defaultPool, silo, unlinkPool, updatePoolLink] + [findDefaultForVersionType, silo, unlinkPool, updatePoolLink] ) const columns = useColsWithActions(staticCols, makeActions) diff --git a/app/table/cells/DefaultPoolCell.tsx b/app/table/cells/DefaultPoolCell.tsx index 066db6402..192d4c44a 100644 --- a/app/table/cells/DefaultPoolCell.tsx +++ b/app/table/cells/DefaultPoolCell.tsx @@ -8,10 +8,16 @@ import { Success12Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' -export const DefaultPoolCell = ({ isDefault }: { isDefault: boolean }) => +export const DefaultPoolCell = ({ + isDefault, + ipVersion, +}: { + isDefault: boolean + ipVersion?: string +}) => isDefault ? ( <> - default + default{ipVersion} ) : null diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index ee7d26a63..564f59f7a 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -62,7 +62,7 @@ export const ipPoolSilos: Json[] = [ { ip_pool_id: ipPool2.id, silo_id: defaultSilo.id, - is_default: false, + is_default: true, // Both v4 and v6 pools are default - valid dual-default scenario }, ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5b870b3be..71ab43ad1 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1079,11 +1079,22 @@ export const handlers = makeHandlers({ const ipPoolSilo = lookup.ipPoolSiloLink(path) // if we're setting default, we need to set is_default false on the existing default + // for the same IP version and pool type (a silo can have separate defaults for v4/v6) if (body.is_default) { const silo = lookup.silo(path) - const existingDefault = db.ipPoolSilos.find( - (ips) => ips.silo_id === silo.id && ips.is_default - ) + const currentPool = lookup.ipPool({ pool: ipPoolSilo.ip_pool_id }) + + // Find existing default with same version and type + const existingDefault = db.ipPoolSilos.find((ips) => { + if (ips.silo_id !== silo.id || !ips.is_default) return false + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + return ( + pool && + pool.ip_version === currentPool.ip_version && + pool.pool_type === currentPool.pool_type + ) + }) + if (existingDefault) { existingDefault.is_default = false } diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index cc653ff48..305a678da 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -88,7 +88,7 @@ test('can create an instance', async ({ page }) => { // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 VPN IPs') + await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 default VPN IPs') // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 1c76229a3..41439e84a 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -264,8 +264,9 @@ test('Silo IP pools', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 // clicking on pool goes to pool detail @@ -273,20 +274,7 @@ test('Silo IP pools', async ({ page }) => { await expect(page).toHaveURL('/system/networking/ip-pools/ip-pool-1') await page.goBack() - // make default - await clickRowAction(page, 'ip-pool-2', 'Make default') - await expect( - page - .getByRole('dialog', { name: 'Confirm change default' }) - .getByText( - 'Are you sure you want to change the default pool from ip-pool-1 to ip-pool-2?' - ) - ).toBeVisible() - await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-1', Default: '' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) - - // unlink + // unlink IPv4 pool await clickRowAction(page, 'ip-pool-1', 'Unlink') await expect( page @@ -295,9 +283,10 @@ test('Silo IP pools', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() + // ip-pool-2 should still be default, but now it's the only default so no version shown await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) - // clear default + // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') await expect( page @@ -312,8 +301,9 @@ test('Silo IP pools link pool', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + // Both pools start as default (one IPv4, one IPv6) + await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) + await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 const modal = page.getByRole('dialog', { name: 'Link pool' }) From a1e52583c3b306365be1faa507456b99fdf3070e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 08:58:44 -0800 Subject: [PATCH 22/73] Simplify default badging --- app/pages/system/silos/SiloIpPoolsTab.tsx | 73 ++++------------------- test/e2e/silos.e2e.ts | 16 ++--- 2 files changed, 20 insertions(+), 69 deletions(-) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 3bec42f85..4043ea4c0 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -12,14 +12,7 @@ import { useCallback, useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' -import { - api, - getListQFn, - queryClient, - useApiMutation, - type IpVersion, - type SiloIpPool, -} from '@oxide/api' +import { api, getListQFn, queryClient, useApiMutation, type SiloIpPool } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -29,7 +22,6 @@ import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -69,74 +61,33 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { return null } -// Helper component that computes dual defaults from table data -function DefaultPoolCellWithContext({ - isDefault, - ipVersion, - allRows, -}: { - isDefault: boolean - ipVersion: IpVersion - allRows: SiloIpPool[] -}) { - // Compute dual defaults from current table data - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = allRows.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' - ) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [allRows]) - - return ( - - ) -} - export default function SiloIpPoolsTab() { const { silo } = useSiloSelector() const [showLinkModal, setShowLinkModal] = useState(false) - // Fetch all pools for the table and for computing dual defaults + // Fetch all_ish, but there should only be a few anyway. Not prefetched + // because the prefetched one only gets 25 to match the query table. This req + // is better to do async because they can't click make default that fast + // anyway. const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) const allPools = allPoolsData?.items - // Define columns const staticCols = useMemo( () => [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), colHelper.accessor('ipVersion', { header: 'IP Version', - cell: (info) => - info.getValue() === 'v4' ? ( - v4 - ) : ( - v6 - ), + cell: (info) => ( + <> + {info.getValue()} + {info.row.original.isDefault && default} + + ), }), colHelper.accessor('poolType', { header: 'Type', - cell: (info) => - info.getValue() === 'unicast' ? ( - Unicast - ) : ( - Multicast - ), - }), - colHelper.accessor('isDefault', { - header: 'Default', - cell: (info) => ( - r.original)} - /> - ), + cell: (info) => {info.getValue()}, }), ], [] diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index 41439e84a..ef0d7aa15 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -265,8 +265,8 @@ test('Silo IP pools', async ({ page }) => { const table = page.getByRole('table') // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) + await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 // clicking on pool goes to pool detail @@ -283,8 +283,8 @@ test('Silo IP pools', async ({ page }) => { ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() - // ip-pool-2 should still be default, but now it's the only default so no version shown - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default' }) + // ip-pool-2 should still be default + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') @@ -294,7 +294,7 @@ test('Silo IP pools', async ({ page }) => { .getByText('Are you sure you want ip-pool-2 to stop being the default') ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-2', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6' }) }) test('Silo IP pools link pool', async ({ page }) => { @@ -302,8 +302,8 @@ test('Silo IP pools link pool', async ({ page }) => { const table = page.getByRole('table') // Both pools start as default (one IPv4, one IPv6) - await expectRowVisible(table, { name: 'ip-pool-1', Default: 'default v4' }) - await expectRowVisible(table, { name: 'ip-pool-2', Default: 'default v6' }) + await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) + await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) await expect(table.getByRole('row')).toHaveCount(3) // header + 2 const modal = page.getByRole('dialog', { name: 'Link pool' }) @@ -332,7 +332,7 @@ test('Silo IP pools link pool', async ({ page }) => { // modal closes and we see the thing in the table await expect(modal).toBeHidden() - await expectRowVisible(table, { name: 'ip-pool-3', Default: '' }) + await expectRowVisible(table, { name: 'ip-pool-3', 'IP Version': 'v4' }) }) // just a convenient form to test this with because it's tall From 5ca1528b6bfb19811830b7c9a4c5e06a0c3e301a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 09:57:52 -0800 Subject: [PATCH 23/73] Fix v6 automatic pool assignment issue --- app/components/AttachEphemeralIpModal.tsx | 2 +- app/forms/network-interface-create.tsx | 10 +++++-- app/util/ip.ts | 6 ++-- mock-api/msw/db.ts | 36 +++++++++++++++++++---- 4 files changed, 43 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 2a30ce5b7..492ba26f5 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -65,7 +65,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) -
+ pool ? lookup.ipPool({ pool }) : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) export const resolvePoolSelector = ( - poolSelector: { pool: string; type: 'explicit' } | { type: 'auto' } | undefined -) => - poolSelector?.type === 'explicit' - ? lookup.ipPool({ pool: poolSelector.pool }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + poolSelector: + | { pool: string; type: 'explicit' } + | { type: 'auto'; ip_version?: IpVersion | null } + | undefined +) => { + if (poolSelector?.type === 'explicit') { + return lookup.ipPool({ pool: poolSelector.pool }) + } + + // For 'auto' type, find the default pool for the specified IP version (or any default if not specified) + const silo = lookup.silo({ silo: defaultSilo.id }) + const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) + + if (poolSelector?.ip_version) { + // Find default pool matching the specified IP version + const link = links.find((ips) => { + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + return pool?.ip_version === poolSelector.ip_version + }) + if (link) { + return lookupById(db.ipPools, link.ip_pool_id) + } + } + + // Fall back to any default pool (for backwards compatibility) + const link = links[0] + if (!link) throw notFoundErr(`default pool for silo '${defaultSilo.id}'`) + return lookupById(db.ipPools, link.ip_pool_id) +} export const getIpFromPool = (pool: Json) => { const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) From f224a463460da818130fa4f70a1302784a3c2869 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 11:56:13 -0800 Subject: [PATCH 24/73] Remove DefaultPoolCell --- app/pages/system/networking/IpPoolPage.tsx | 16 ++++++++++++--- app/table/cells/DefaultPoolCell.tsx | 23 ---------------------- 2 files changed, 13 insertions(+), 26 deletions(-) delete mode 100644 app/table/cells/DefaultPoolCell.tsx diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 93c3f270c..f8ef4ee60 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -22,7 +22,12 @@ import { type IpPoolRange, type IpPoolSiloLink, } from '@oxide/api' -import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' +import { + IpGlobal16Icon, + IpGlobal24Icon, + Success12Icon, +} from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' import { CapacityBar } from '~/components/CapacityBar' import { DocsPopover } from '~/components/DocsPopover' @@ -35,7 +40,6 @@ import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' -import { DefaultPoolCell } from '~/table/cells/DefaultPoolCell' import { SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -326,7 +330,13 @@ function LinkedSilosTable() { ) }, - cell: (info) => , + cell: (info) => + info.getValue() ? ( + <> + + default + + ) : null, }), ], [] diff --git a/app/table/cells/DefaultPoolCell.tsx b/app/table/cells/DefaultPoolCell.tsx deleted file mode 100644 index 192d4c44a..000000000 --- a/app/table/cells/DefaultPoolCell.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { Success12Icon } from '@oxide/design-system/icons/react' -import { Badge } from '@oxide/design-system/ui' - -export const DefaultPoolCell = ({ - isDefault, - ipVersion, -}: { - isDefault: boolean - ipVersion?: string -}) => - isDefault ? ( - <> - - default{ipVersion} - - ) : null From a9971f2b45569f9d637e2ac875c386a25cdf7c67 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 12:38:50 -0800 Subject: [PATCH 25/73] Ensure unicast pools are used for ephemeral IP form --- app/components/AttachEphemeralIpModal.tsx | 39 +++++++++++++++++++++-- app/ui/lib/Modal.tsx | 3 ++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 492ba26f5..4ce3f3d3d 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -32,6 +32,16 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo(() => { + if (!siloPools) return [] + return siloPools.items.filter((p) => p.poolType === 'unicast') + }, [siloPools]) + + const hasDefaultUnicastPool = useMemo(() => { + return unicastPools.some((p) => p.isDefault) + }, [unicastPools]) + // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = useMemo(() => { if (!siloPools) return false @@ -61,6 +71,15 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const pool = form.watch('pool') const ipVersion = form.watch('ipVersion') + const getDisabledReason = () => { + if (!siloPools) return 'Loading pools...' + if (unicastPools.length === 0) return 'No unicast pools available' + if (!pool && !hasDefaultUnicastPool) { + return 'No default pool available; select a pool to continue' + } + return undefined + } + return ( @@ -70,8 +89,19 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) control={form.control} name="pool" label="IP pool" - placeholder="Default pool" - items={(siloPools?.items ?? []).map(toIpPoolItem)} + placeholder={ + unicastPools.length === 0 + ? 'No unicast pools available' + : hasDefaultUnicastPool + ? 'Default pool' + : 'Select a pool (no default available)' + } + description={ + unicastPools.length === 0 + ? 'Contact your administrator to create a unicast IP pool' + : undefined + } + items={unicastPools.map(toIpPoolItem)} /> {!pool && hasDualDefaults && ( void }) { instanceEphemeralIpAttach.mutate({ path: { instance }, diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 0f90eb8b9..60e116849 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -113,6 +113,7 @@ type FooterProps = { actionLoading?: boolean cancelText?: string disabled?: boolean + disabledReason?: React.ReactNode showCancel?: boolean } & MergeExclusive<{ formId: string }, { onAction: () => void }> @@ -125,6 +126,7 @@ Modal.Footer = ({ actionLoading, cancelText, disabled, + disabledReason, formId, showCancel = true, }: FooterProps) => ( @@ -143,6 +145,7 @@ Modal.Footer = ({ variant={actionType} onClick={onAction} disabled={!!disabled} + disabledReason={disabledReason} loading={actionLoading} > {actionText} From 9037197a82a511393447051afcccc1f0efebe9a8 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 15:52:05 -0800 Subject: [PATCH 26/73] Fix incorrect pool issue with Floating IP create flow --- app/forms/floating-ip-create.tsx | 53 ++++++++++++++-- app/forms/instance-create.tsx | 104 +++++++++++++++++++++++++------ mock-api/msw/handlers.ts | 32 +++++----- 3 files changed, 148 insertions(+), 41 deletions(-) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 7e5366334..a6a33ddee 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -7,11 +7,18 @@ */ import * as Accordion from '@radix-ui/react-accordion' import { useQuery } from '@tanstack/react-query' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' -import { api, q, queryClient, useApiMutation, type FloatingIpCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type FloatingIpCreate, + type IpVersion, +} from '@oxide/api' import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' @@ -31,11 +38,13 @@ type FloatingIpCreateFormData = { name: string description: string pool?: string + ipVersion: IpVersion } const defaultValues: FloatingIpCreateFormData = { name: '', description: '', + ipVersion: 'v4', } export const handle = titleCrumb('New Floating IP') @@ -47,6 +56,20 @@ export default function CreateFloatingIpSideModalForm() { q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + // Only unicast pools can be used for floating IPs + const unicastPools = useMemo(() => { + if (!allPools) return [] + return allPools.items.filter((p) => p.poolType === 'unicast') + }, [allPools]) + + // Detect if both IPv4 and IPv6 default unicast pools exist + const hasDualDefaults = useMemo(() => { + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + return hasV4Default && hasV6Default + }, [unicastPools]) + const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -61,6 +84,7 @@ export default function CreateFloatingIpSideModalForm() { }) const form = useForm({ defaultValues }) + const pool = form.watch('pool') const [openItems, setOpenItems] = useState([]) @@ -70,7 +94,7 @@ export default function CreateFloatingIpSideModalForm() { formType="create" resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} - onSubmit={({ pool, ...values }) => { + onSubmit={({ pool, ipVersion, ...values }) => { const body: FloatingIpCreate = { ...values, addressAllocator: pool @@ -78,7 +102,12 @@ export default function CreateFloatingIpSideModalForm() { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, } - : undefined, + : hasDualDefaults + ? { + type: 'auto' as const, + poolSelector: { type: 'auto' as const, ipVersion }, + } + : undefined, } createFloatingIp.mutate({ query: projectSelector, body }) }} @@ -106,11 +135,25 @@ export default function CreateFloatingIpSideModalForm() { + + {!pool && hasDualDefaults && ( + + )} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 702991c74..8b8c3ffae 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -26,6 +26,7 @@ import { type Image, type InstanceCreate, type InstanceDiskAttachment, + type IpVersion, type NameOrId, type SiloIpPool, } from '@oxide/api' @@ -50,6 +51,7 @@ import { import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' +import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -127,6 +129,8 @@ export type InstanceCreateInput = Assign< userData: File | null // ssh keys are always specified. we do not need the undefined case sshPublicKeys: NonNullable + // IP version for ephemeral IP when dual defaults exist + ephemeralIpVersion: IpVersion } > @@ -159,6 +163,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], + ephemeralIpVersion: 'v4', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -243,6 +248,8 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), + // When dual defaults exist and no explicit pool, default to v4 for dual_stack + ephemeralIpVersion: 'v4', externalIps: defaultPool ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] : hasDualDefaults @@ -273,6 +280,20 @@ export default function CreateInstanceForm() { } }, [createInstance.error]) + // Watch networkInterfaces and update ephemeralIpVersion when dual defaults exist + const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) + useEffect(() => { + if (!hasDualDefaults) return + + // Couple ephemeral IP version to network interface type when dual defaults exist + if (networkInterfaces.type === 'default_ipv6') { + setValue('ephemeralIpVersion', 'v6') + } else if (networkInterfaces.type === 'default_ipv4') { + setValue('ephemeralIpVersion', 'v4') + } + // For default_dual_stack, leave as-is (user can choose in UI) + }, [networkInterfaces, hasDualDefaults, setValue]) + const otherDisks = useWatch({ control, name: 'otherDisks' }) const unavailableDiskNames = [ ...allDisks, // existing disks from the API @@ -612,6 +633,7 @@ export default function CreateInstanceForm() { isSubmitting={isSubmitting} siloPools={siloPools.items} hasDualDefaults={hasDualDefaults} + defaultPool={defaultPool} /> Create instance @@ -649,11 +671,13 @@ const AdvancedAccordion = ({ isSubmitting, siloPools, hasDualDefaults, + defaultPool, }: { control: Control isSubmitting: boolean siloPools: Array hasDualDefaults: boolean + defaultPool?: string }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -668,9 +692,31 @@ const AdvancedAccordion = ({ ephemeralIp?.poolSelector?.type === 'explicit' ? ephemeralIp.poolSelector.pool : undefined - const defaultPool = siloPools.find((pool) => pool.isDefault)?.name const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) + const ephemeralIpVersionField = useController({ control, name: 'ephemeralIpVersion' }) + + // Update externalIps when ephemeralIpVersion changes and no explicit pool is selected + useEffect(() => { + if (!hasDualDefaults || !assignEphemeralIp || selectedPool) return + + const ipVersion = ephemeralIpVersionField.field.value || 'v4' + const newExternalIps = externalIps.field.value?.map((ip) => + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'auto', ipVersion } } + : ip + ) + if (newExternalIps) { + externalIps.field.onChange(newExternalIps) + } + }, [ + ephemeralIpVersionField.field.value, + hasDualDefaults, + assignEphemeralIp, + selectedPool, + externalIps, + ]) + const instanceName = useWatch({ control, name: 'name' }) const { project } = useProjectSelector() @@ -773,7 +819,10 @@ const AdvancedAccordion = ({ : hasDualDefaults ? { type: 'ephemeral', - poolSelector: { type: 'auto', ipVersion: 'v4' }, + poolSelector: { + type: 'auto', + ipVersion: ephemeralIpVersionField.field.value || 'v4', + }, } : { type: 'ephemeral' }, ] @@ -783,23 +832,40 @@ const AdvancedAccordion = ({ Allocate and attach an ephemeral IP address {assignEphemeralIp && ( - pool.name === selectedPool)?.name}`} - items={siloPools.map(toIpPoolItem)} - disabled={!assignEphemeralIp || isSubmitting} - required - onChange={(value) => { - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } - : ip - ) - externalIps.field.onChange(newExternalIps) - }} - /> + <> + pool.name === selectedPool)?.name}`} + items={siloPools.map(toIpPoolItem)} + disabled={!assignEphemeralIp || isSubmitting} + required + onChange={(value) => { + const newExternalIps = externalIps.field.value?.map((ip) => + ip.type === 'ephemeral' + ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + : ip + ) + externalIps.field.onChange(newExternalIps) + }} + /> + + {!selectedPool && hasDualDefaults && ( + + )} + )}
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 71ab43ad1..47d29b491 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -305,28 +305,26 @@ export const handlers = makeHandlers({ const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) - // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool const addressAllocator = body.address_allocator || { type: 'auto' } - const pool = - addressAllocator.type === 'explicit' && addressAllocator.pool - ? lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) - : addressAllocator.type === 'auto' && - addressAllocator.pool_selector?.type === 'explicit' - ? lookup.siloIpPool({ - pool: addressAllocator.pool_selector.pool, - silo: defaultSilo.id, - }) - : lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + + // Determine the pool, respecting ipVersion when specified + let pool: Json + if (addressAllocator.type === 'explicit' && addressAllocator.pool) { + pool = lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) + } else if (addressAllocator.type === 'auto') { + pool = resolvePoolSelector(addressAllocator.pool_selector) + } else { + pool = lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + } + + // Generate IP from the pool (respects pool's IP version) + const ip = + (addressAllocator.type === 'explicit' && addressAllocator.ip) || getIpFromPool(pool) const newFloatingIp: Json = { id: uuid(), project_id: project.id, - // TODO: use ip-num to actually get the next available IP in the pool - ip: - (addressAllocator.type === 'explicit' && addressAllocator.ip) || - Array.from({ length: 4 }) - .map(() => Math.floor(Math.random() * 256)) - .join('.'), + ip, ip_pool_id: pool.id, description: body.description, name: body.name, From 54768625b792eda2154baa8830fd11e6a404caba Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 16:36:26 -0800 Subject: [PATCH 27/73] Proper handling of unicast pools in instance create --- app/forms/instance-create.tsx | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 8b8c3ffae..dacd6da4c 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -224,24 +224,30 @@ export default function CreateInstanceForm() { const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) - const defaultPool = useMemo( - () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined), + + // Only unicast pools can be used for ephemeral IPs + const unicastPools = useMemo( + () => siloPools?.items.filter((p) => p.poolType === 'unicast') || [], [siloPools] ) - const defaultSource = - siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' - // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = useMemo(() => { - if (!siloPools) return false - const defaultUnicastPools = siloPools.items.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' - ) + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') return hasV4Default && hasV6Default - }, [siloPools]) + }, [unicastPools]) + + // Only use a default pool if exactly one unicast default exists + // When dual defaults exist, we'll use { type: 'auto', ipVersion } instead + const defaultPool = useMemo(() => { + if (hasDualDefaults) return undefined + return unicastPools.find((p) => p.isDefault)?.name + }, [unicastPools, hasDualDefaults]) + + const defaultSource = + siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' const defaultValues: InstanceCreateInput = { ...baseDefaultValues, @@ -631,7 +637,7 @@ export default function CreateInstanceForm() { @@ -669,13 +675,13 @@ const FloatingIpLabel = ({ ip }: { ip: FloatingIp }) => ( const AdvancedAccordion = ({ control, isSubmitting, - siloPools, + unicastPools, hasDualDefaults, defaultPool, }: { control: Control isSubmitting: boolean - siloPools: Array + unicastPools: Array hasDualDefaults: boolean defaultPool?: string }) => { @@ -709,12 +715,13 @@ const AdvancedAccordion = ({ if (newExternalIps) { externalIps.field.onChange(newExternalIps) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ ephemeralIpVersionField.field.value, hasDualDefaults, assignEphemeralIp, selectedPool, - externalIps, + // NOTE: Do not include externalIps in deps - it would cause infinite loop ]) const instanceName = useWatch({ control, name: 'name' }) @@ -837,14 +844,17 @@ const AdvancedAccordion = ({ name="pools" label="IP pool for ephemeral IP" placeholder={defaultPool ? `${defaultPool} (default)` : 'Select a pool'} - selected={`${siloPools.find((pool) => pool.name === selectedPool)?.name}`} - items={siloPools.map(toIpPoolItem)} + selected={`${unicastPools.find((pool) => pool.name === selectedPool)?.name}`} + items={unicastPools.map(toIpPoolItem)} disabled={!assignEphemeralIp || isSubmitting} required onChange={(value) => { const newExternalIps = externalIps.field.value?.map((ip) => ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'explicit', pool: value } } + ? { + type: 'ephemeral', + poolSelector: { type: 'explicit', pool: value }, + } : ip ) externalIps.field.onChange(newExternalIps) From c194ff35d273f67fc8eb81fbfc1d45c5b48a9e72 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 19:34:06 -0800 Subject: [PATCH 28/73] make sure external IP version matches NIC type --- app/forms/instance-create.tsx | 47 ++++++++++++++++++-------- mock-api/ip-pool.ts | 62 +++++++++++++++++++++++++++++++++-- mock-api/msw/db.ts | 39 +++++++++++++--------- mock-api/msw/handlers.ts | 42 +++++++++++++++++++++--- 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index dacd6da4c..97b7c7af4 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -640,6 +640,7 @@ export default function CreateInstanceForm() { unicastPools={unicastPools} hasDualDefaults={hasDualDefaults} defaultPool={defaultPool} + networkInterfaces={networkInterfaces} /> Create instance @@ -678,12 +679,14 @@ const AdvancedAccordion = ({ unicastPools, hasDualDefaults, defaultPool, + networkInterfaces, }: { control: Control isSubmitting: boolean unicastPools: Array hasDualDefaults: boolean defaultPool?: string + networkInterfaces: InstanceCreate['networkInterfaces'] }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -861,20 +864,36 @@ const AdvancedAccordion = ({ }} /> - {!selectedPool && hasDualDefaults && ( - - )} + {!selectedPool && + hasDualDefaults && + (() => { + // Determine which IP versions are compatible with the NIC + // Based on Omicron validation: external IP version must match NIC's private IP stack + const nicType = networkInterfaces?.type + const compatibleVersions: Array<{ label: string; value: 'v4' | 'v6' }> = + [] + + if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { + compatibleVersions.push({ label: 'IPv4', value: 'v4' }) + } + if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { + compatibleVersions.push({ label: 'IPv6', value: 'v6' }) + } + + // Only show selector if there's a choice to make + if (compatibleVersions.length <= 1) return null + + return ( + + ) + })()} )}
diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 564f59f7a..c58813344 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -51,7 +51,35 @@ export const ipPool4: Json = { pool_type: 'unicast', } -export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3, ipPool4] +// Multicast pools for testing that they are NOT selected for ephemeral/floating IPs +export const ipPool5Multicast: Json = { + id: 'b6c4a6b9-761e-4d28-94c0-fd3d7738ef1d', + name: 'ip-pool-5-multicast-v4', + description: 'Multicast v4 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v4', + pool_type: 'multicast', +} + +export const ipPool6Multicast: Json = { + id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849fg2e', + name: 'ip-pool-6-multicast-v6', + description: 'Multicast v6 pool', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), + ip_version: 'v6', + pool_type: 'multicast', +} + +export const ipPools: Json[] = [ + ipPool1, + ipPool2, + ipPool3, + ipPool4, + ipPool5Multicast, + ipPool6Multicast, +] export const ipPoolSilos: Json[] = [ { @@ -62,7 +90,18 @@ export const ipPoolSilos: Json[] = [ { ip_pool_id: ipPool2.id, silo_id: defaultSilo.id, - is_default: true, // Both v4 and v6 pools are default - valid dual-default scenario + is_default: true, // Both v4 and v6 unicast pools are default - valid dual-default scenario + }, + // Make multicast pools also default to test that they are NOT selected + { + ip_pool_id: ipPool5Multicast.id, + silo_id: defaultSilo.id, + is_default: true, + }, + { + ip_pool_id: ipPool6Multicast.id, + silo_id: defaultSilo.id, + is_default: true, }, ] @@ -105,4 +144,23 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + // Multicast pool ranges (should NOT be used for ephemeral/floating IPs) + { + id: 'e8f6c8db-983g-4f4a-a6e2-gf5f9960gh3f', + ip_pool_id: ipPool5Multicast.id, + range: { + first: '224.0.0.1', + last: '224.0.0.20', + }, + time_created: new Date().toISOString(), + }, + { + id: 'f9g7d9ec-a94h-5g5b-b7f3-hg6ga071hi4g', + ip_pool_id: ipPool6Multicast.id, + range: { + first: 'ff00::1', + last: 'ff00::20', + }, + time_created: new Date().toISOString(), + }, ] diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 0cb481a4f..648ddac52 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -10,7 +10,7 @@ import * as R from 'remeda' import { validate as isUuid } from 'uuid' -import type { ApiTypes as Api, IpVersion } from '@oxide/api' +import type { ApiTypes as Api, IpPoolType, IpVersion } from '@oxide/api' import * as mock from '@oxide/api-mocks' import { json } from '~/api/__generated__/msw-handlers' @@ -66,30 +66,39 @@ export const resolvePoolSelector = ( poolSelector: | { pool: string; type: 'explicit' } | { type: 'auto'; ip_version?: IpVersion | null } - | undefined + | undefined, + poolType?: IpPoolType ) => { if (poolSelector?.type === 'explicit') { return lookup.ipPool({ pool: poolSelector.pool }) } - // For 'auto' type, find the default pool for the specified IP version (or any default if not specified) + // For 'auto' type, find the default pool for the specified IP version and pool type const silo = lookup.silo({ silo: defaultSilo.id }) const links = db.ipPoolSilos.filter((ips) => ips.silo_id === silo.id && ips.is_default) - if (poolSelector?.ip_version) { - // Find default pool matching the specified IP version - const link = links.find((ips) => { - const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) - return pool?.ip_version === poolSelector.ip_version - }) - if (link) { - return lookupById(db.ipPools, link.ip_pool_id) + // Filter candidate pools by both IP version and pool type + const candidateLinks = links.filter((ips) => { + const pool = db.ipPools.find((p) => p.id === ips.ip_pool_id) + if (!pool) return false + + // If poolType specified, filter by it + if (poolType && pool.pool_type !== poolType) return false + + // If IP version specified, filter by it + if (poolSelector?.ip_version && pool.ip_version !== poolSelector.ip_version) { + return false } - } - // Fall back to any default pool (for backwards compatibility) - const link = links[0] - if (!link) throw notFoundErr(`default pool for silo '${defaultSilo.id}'`) + return true + }) + + const link = candidateLinks[0] + if (!link) { + const typeStr = poolType ? ` ${poolType}` : '' + const versionStr = poolSelector?.ip_version ? ` ${poolSelector.ip_version}` : '' + throw notFoundErr(`default${typeStr}${versionStr} pool for silo '${defaultSilo.id}'`) + } return lookupById(db.ipPools, link.ip_pool_id) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 47d29b491..f43154375 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -308,11 +308,12 @@ export const handlers = makeHandlers({ const addressAllocator = body.address_allocator || { type: 'auto' } // Determine the pool, respecting ipVersion when specified + // Floating IPs must use unicast pools let pool: Json if (addressAllocator.type === 'explicit' && addressAllocator.pool) { pool = lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) } else if (addressAllocator.type === 'auto') { - pool = resolvePoolSelector(addressAllocator.pool_selector) + pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') } else { pool = lookup.siloDefaultIpPool({ silo: defaultSilo.id }) } @@ -516,6 +517,14 @@ export const handlers = makeHandlers({ } // validate floating IP attachments before we actually do anything + // Determine what IP stacks the instance will have based on network interfaces + const hasIpv4Nic = + body.network_interfaces?.type === 'default_ipv4' || + body.network_interfaces?.type === 'default_dual_stack' + const hasIpv6Nic = + body.network_interfaces?.type === 'default_ipv6' || + body.network_interfaces?.type === 'default_dual_stack' + body.external_ips?.forEach((ip) => { if (ip.type === 'floating') { // throw if floating IP doesn't exist @@ -531,8 +540,31 @@ export const handlers = makeHandlers({ // if there are no ranges in the pool or if the pool doesn't exist, // which aren't quite as good as checking that there are actually IPs // available, but they are good things to check - const pool = resolvePoolSelector(ip.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') getIpFromPool(pool) + + // Validate that external IP version matches NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const ipVersion = pool.ip_version + if (ipVersion === 'v4' && !hasIpv4Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + if (ipVersion === 'v6' && !hasIpv6Nic) { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${body.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } } }) @@ -642,7 +674,8 @@ export const handlers = makeHandlers({ // we've already validated that the IP isn't attached floatingIp.instance_id = instanceId } else if (ip.type === 'ephemeral') { - const pool = resolvePoolSelector(ip.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(ip.pool_selector, 'unicast') const firstAvailableAddress = getIpFromPool(pool) db.ephemeralIps.push({ @@ -824,7 +857,8 @@ export const handlers = makeHandlers({ }, instanceEphemeralIpAttach({ path, query: projectParams, body }) { const instance = lookup.instance({ ...path, ...projectParams }) - const pool = resolvePoolSelector(body.pool_selector) + // Ephemeral IPs must use unicast pools + const pool = resolvePoolSelector(body.pool_selector, 'unicast') const ip = getIpFromPool(pool) const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } From d45997f644e99433c38c4644d3bae8d688446008 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:15:07 -0800 Subject: [PATCH 29/73] Better flow for IP Pool selector; add component --- app/components/AttachEphemeralIpModal.tsx | 91 ++++---- app/components/form/fields/IpPoolSelector.tsx | 133 +++++++++++ app/forms/floating-ip-create.tsx | 54 ++--- app/forms/instance-create.tsx | 221 +++++++++--------- 4 files changed, 296 insertions(+), 203 deletions(-) create mode 100644 app/components/form/fields/IpPoolSelector.tsx diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 4ce3f3d3d..76f166020 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { @@ -17,20 +17,21 @@ import { usePrefetchedQuery, type IpVersion, } from '~/api' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' -import { toIpPoolItem } from './form/fields/ip-pool-item' - export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() const { data: siloPools } = usePrefetchedQuery( q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) + const { data: nics } = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { query: { project, instance } }) + ) // Only unicast pools can be used for ephemeral IPs const unicastPools = useMemo(() => { @@ -42,16 +43,24 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return unicastPools.some((p) => p.isDefault) }, [unicastPools]) - // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - if (!siloPools) return false - const defaultUnicastPools = siloPools.items.filter( - (pool) => pool.isDefault && pool.poolType === 'unicast' + // Determine compatible IP versions based on instance's network interfaces + // External IP version must match the NIC's private IP stack + const compatibleVersions: IpVersion[] = useMemo(() => { + if (!nics) return [] + + const nicItems = nics.items + const hasV4Nic = nicItems.some( + (nic) => nic.ipStack.type === 'v4' || nic.ipStack.type === 'dual_stack' + ) + const hasV6Nic = nicItems.some( + (nic) => nic.ipStack.type === 'v6' || nic.ipStack.type === 'dual_stack' ) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [siloPools]) + + const versions: IpVersion[] = [] + if (hasV4Nic) versions.push('v4') + if (hasV6Nic) versions.push('v6') + return versions + }, [nics]) const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { @@ -66,8 +75,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) }) const form = useForm<{ pool: string; ipVersion: IpVersion }>({ - defaultValues: { pool: '', ipVersion: 'v4' }, + defaultValues: { + pool: '', + ipVersion: 'v4', + }, }) + + // Update ipVersion if only one version is compatible + useEffect(() => { + if (compatibleVersions.length === 1) { + form.setValue('ipVersion', compatibleVersions[0]) + } + }, [compatibleVersions, form]) const pool = form.watch('pool') const ipVersion = form.watch('ipVersion') @@ -84,38 +103,18 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) - - + - {!pool && hasDualDefaults && ( - - )} @@ -131,9 +130,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : hasDualDefaults - ? { poolSelector: { type: 'auto', ipVersion } } - : { poolSelector: { type: 'auto' } }, + : { poolSelector: { type: 'auto', ipVersion } }, }) }} onDismiss={onDismiss} diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx new file mode 100644 index 000000000..8377e7222 --- /dev/null +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -0,0 +1,133 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import type { Control, UseFormSetValue } from 'react-hook-form' + +import type { IpVersion, SiloIpPool } from '@oxide/api' + +import { Radio } from '~/ui/lib/Radio' + +import { toIpPoolItem } from './ip-pool-item' +import { ListboxField } from './ListboxField' + +type IpPoolSelectorProps = { + control: Control + poolFieldName: string + ipVersionFieldName: string + pools: SiloIpPool[] + /** Current value of the pool field - used to determine radio selection */ + currentPool: string | undefined + /** Current value of the IP version field - used to determine radio selection */ + currentIpVersion: IpVersion + /** Function to update form values */ + setValue: UseFormSetValue + disabled?: boolean + /** + * Compatible IP versions based on network interface type + * If not provided, both v4 and v6 are allowed + */ + compatibleVersions?: IpVersion[] +} + +/** + * IP Pool selector with radio button pattern: + * - "IPv4 default" (if v4 default exists and is compatible) + * - "IPv6 default" (if v6 default exists and is compatible) + * - "Use custom pool" (with pool dropdown) + */ +export function IpPoolSelector({ + control, + poolFieldName, + ipVersionFieldName, + pools, + currentPool, + currentIpVersion, + setValue, + disabled = false, + compatibleVersions, +}: IpPoolSelectorProps) { + // Determine which default pool versions exist + const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') + const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') + + // Filter default options by compatible versions + const showV4Default = + hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) + const showV6Default = + hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) + + // Derive current selection from pool and ipVersion + type SelectionType = 'v4-default' | 'v6-default' | 'custom' + const currentSelection: SelectionType = currentPool + ? 'custom' + : currentIpVersion === 'v6' + ? 'v6-default' + : 'v4-default' + + return ( +
+
+ Select IP pool +
+ {showV4Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + }} + disabled={disabled} + > + IPv4 default + + )} + {showV6Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + }} + disabled={disabled} + > + IPv6 default + + )} + { + // Set to first pool in list so the dropdown shows with a valid selection + if (pools.length > 0) { + setValue(poolFieldName, pools[0].name) + } + }} + disabled={disabled} + > + custom pool + +
+
+ + {currentSelection === 'custom' && ( + + )} +
+ ) +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index a6a33ddee..d545023bf 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -22,15 +22,13 @@ import { import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' -import { Message } from '~/ui/lib/Message' import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' @@ -62,14 +60,6 @@ export default function CreateFloatingIpSideModalForm() { return allPools.items.filter((p) => p.poolType === 'unicast') }, [allPools]) - // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [unicastPools]) - const projectSelector = useProjectSelector() const navigate = useNavigate() @@ -85,6 +75,7 @@ export default function CreateFloatingIpSideModalForm() { const form = useForm({ defaultValues }) const pool = form.watch('pool') + const ipVersion = form.watch('ipVersion') const [openItems, setOpenItems] = useState([]) @@ -102,12 +93,10 @@ export default function CreateFloatingIpSideModalForm() { type: 'auto' as const, poolSelector: { type: 'explicit' as const, pool }, } - : hasDualDefaults - ? { - type: 'auto' as const, - poolSelector: { type: 'auto' as const, ipVersion }, - } - : undefined, + : { + type: 'auto' as const, + poolSelector: { type: 'auto' as const, ipVersion }, + }, } createFloatingIp.mutate({ query: projectSelector, body }) }} @@ -128,32 +117,15 @@ export default function CreateFloatingIpSideModalForm() { label="Advanced" value="advanced" > - - - - - {!pool && hasDualDefaults && ( - - )} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 97b7c7af4..645a914d1 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -7,7 +7,13 @@ */ import * as Accordion from '@radix-ui/react-accordion' import { useEffect, useMemo, useState } from 'react' -import { useController, useForm, useWatch, type Control } from 'react-hook-form' +import { + useController, + useForm, + useWatch, + type Control, + type UseFormSetValue, +} from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' @@ -50,8 +56,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' -import { ListboxField } from '~/components/form/fields/ListboxField' +import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -131,6 +136,8 @@ export type InstanceCreateInput = Assign< sshPublicKeys: NonNullable // IP version for ephemeral IP when dual defaults exist ephemeralIpVersion: IpVersion + // Pool for ephemeral IP - used to sync with IpPoolSelector component + ephemeralIpPool: string } > @@ -164,6 +171,7 @@ const baseDefaultValues: InstanceCreateInput = { userData: null, externalIps: [{ type: 'ephemeral' }], ephemeralIpVersion: 'v4', + ephemeralIpPool: '', } export async function clientLoader({ params }: LoaderFunctionArgs) { @@ -239,16 +247,15 @@ export default function CreateInstanceForm() { return hasV4Default && hasV6Default }, [unicastPools]) - // Only use a default pool if exactly one unicast default exists - // When dual defaults exist, we'll use { type: 'auto', ipVersion } instead - const defaultPool = useMemo(() => { - if (hasDualDefaults) return undefined - return unicastPools.find((p) => p.isDefault)?.name - }, [unicastPools, hasDualDefaults]) const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' + // Calculate if there's a single default pool (not dual defaults) + const singleDefaultPool = !hasDualDefaults + ? unicastPools.find((p) => p.isDefault)?.name + : undefined + const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -256,8 +263,10 @@ export default function CreateInstanceForm() { bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), // When dual defaults exist and no explicit pool, default to v4 for dual_stack ephemeralIpVersion: 'v4', - externalIps: defaultPool - ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: defaultPool } }] + // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") + ephemeralIpPool: '', + externalIps: singleDefaultPool + ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: singleDefaultPool } }] : hasDualDefaults ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] : [{ type: 'ephemeral' }], @@ -638,9 +647,8 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} unicastPools={unicastPools} - hasDualDefaults={hasDualDefaults} - defaultPool={defaultPool} networkInterfaces={networkInterfaces} + setValue={setValue} /> Create instance @@ -677,16 +685,14 @@ const AdvancedAccordion = ({ control, isSubmitting, unicastPools, - hasDualDefaults, - defaultPool, networkInterfaces, + setValue, }: { control: Control isSubmitting: boolean unicastPools: Array - hasDualDefaults: boolean - defaultPool?: string networkInterfaces: InstanceCreate['networkInterfaces'] + setValue: UseFormSetValue }) => { // we track this state manually for the sole reason that we need to be able to // tell, inside AccordionItem, when an accordion is opened so we can scroll its @@ -697,33 +703,60 @@ const AdvancedAccordion = ({ const externalIps = useController({ control, name: 'externalIps' }) const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral') const assignEphemeralIp = !!ephemeralIp - const selectedPool = - ephemeralIp?.poolSelector?.type === 'explicit' - ? ephemeralIp.poolSelector.pool - : undefined const attachedFloatingIps = (externalIps.field.value || []).filter(isFloating) const ephemeralIpVersionField = useController({ control, name: 'ephemeralIpVersion' }) + const ephemeralIpPoolField = useController({ control, name: 'ephemeralIpPool' }) + + const ephemeralIpPool = ephemeralIpPoolField.field.value - // Update externalIps when ephemeralIpVersion changes and no explicit pool is selected + // Initialize ephemeralIpPool once on mount if externalIps already has an explicit pool useEffect(() => { - if (!hasDualDefaults || !assignEphemeralIp || selectedPool) return + const initialPool = + ephemeralIp?.poolSelector?.type === 'explicit' + ? ephemeralIp.poolSelector.pool + : undefined + if (initialPool && !ephemeralIpPool) { + ephemeralIpPoolField.field.onChange(initialPool) + } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Update externalIps when ephemeralIpPool or ephemeralIpVersion changes + useEffect(() => { + if (!assignEphemeralIp) return + + const pool = ephemeralIpPoolField.field.value const ipVersion = ephemeralIpVersionField.field.value || 'v4' - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { type: 'ephemeral', poolSelector: { type: 'auto', ipVersion } } - : ip - ) + + const newExternalIps = externalIps.field.value?.map((ip) => { + if (ip.type !== 'ephemeral') return ip + + // Explicit pool selected + if (pool) { + return { + type: 'ephemeral', + poolSelector: { type: 'explicit', pool }, + } + } + + // No pool selected - use default with explicit IP version + // User selected v4 or v6 via radio button + return { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion }, + } + }) + if (newExternalIps) { externalIps.field.onChange(newExternalIps) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ + ephemeralIpPoolField.field.value, ephemeralIpVersionField.field.value, - hasDualDefaults, assignEphemeralIp, - selectedPool, // NOTE: Do not include externalIps in deps - it would cause infinite loop ]) @@ -810,92 +843,50 @@ const AdvancedAccordion = ({ it is deleted - { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [ - ...(externalIps.field.value || []), - selectedPool || defaultPool - ? { - type: 'ephemeral', - poolSelector: { - type: 'explicit', - pool: selectedPool || defaultPool, - }, - } - : hasDualDefaults - ? { - type: 'ephemeral', - poolSelector: { - type: 'auto', - ipVersion: ephemeralIpVersionField.field.value || 'v4', - }, - } - : { type: 'ephemeral' }, - ] - externalIps.field.onChange(newExternalIps) - }} - > - Allocate and attach an ephemeral IP address - - {assignEphemeralIp && ( - <> - pool.name === selectedPool)?.name}`} - items={unicastPools.map(toIpPoolItem)} - disabled={!assignEphemeralIp || isSubmitting} - required - onChange={(value) => { - const newExternalIps = externalIps.field.value?.map((ip) => - ip.type === 'ephemeral' - ? { - type: 'ephemeral', - poolSelector: { type: 'explicit', pool: value }, - } - : ip - ) - externalIps.field.onChange(newExternalIps) - }} - /> - {!selectedPool && - hasDualDefaults && - (() => { - // Determine which IP versions are compatible with the NIC - // Based on Omicron validation: external IP version must match NIC's private IP stack - const nicType = networkInterfaces?.type - const compatibleVersions: Array<{ label: string; value: 'v4' | 'v6' }> = - [] - - if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { - compatibleVersions.push({ label: 'IPv4', value: 'v4' }) - } - if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { - compatibleVersions.push({ label: 'IPv6', value: 'v6' }) - } - - // Only show selector if there's a choice to make - if (compatibleVersions.length <= 1) return null - - return ( - - ) - })()} - - )} + {/* Calculate compatible IP versions based on NIC type */} + {(() => { + const nicType = networkInterfaces?.type + const compatibleVersions: IpVersion[] = [] + + if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { + compatibleVersions.push('v4') + } + if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { + compatibleVersions.push('v6') + } + + return ( + <> + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + {assignEphemeralIp && ( + + )} + + ) + })()}
From d49504913e44a0905d550d7f7626b5b2837e82b9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:21:32 -0800 Subject: [PATCH 30/73] only show NIC-version-matching IP pools --- app/components/form/fields/IpPoolSelector.tsx | 13 +++++++++---- app/components/form/fields/ip-pool-item.tsx | 3 +++ app/forms/instance-create.tsx | 1 - 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 8377e7222..be7e84284 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -50,6 +50,11 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { + // Filter pools by compatible versions for custom pool dropdown + const filteredPools = compatibleVersions + ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) + : pools + // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') @@ -106,9 +111,9 @@ export function IpPoolSelector({ value="custom" checked={currentSelection === 'custom'} onChange={() => { - // Set to first pool in list so the dropdown shows with a valid selection - if (pools.length > 0) { - setValue(poolFieldName, pools[0].name) + // Set to first compatible pool in list so the dropdown shows with a valid selection + if (filteredPools.length > 0) { + setValue(poolFieldName, filteredPools[0].name) } }} disabled={disabled} @@ -121,7 +126,7 @@ export function IpPoolSelector({ {currentSelection === 'custom' && ( )} + + {p.ipVersion} +
{!!p.description && (
{p.description}
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 645a914d1..0d0e74da9 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -247,7 +247,6 @@ export default function CreateInstanceForm() { return hasV4Default && hasV6Default }, [unicastPools]) - const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' From 640dd6f263f273b68f58a0097cf0fb38eb6a2d10 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 21:29:00 -0800 Subject: [PATCH 31/73] fix crashing IP Pools list --- app/pages/system/networking/IpPoolsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index b35a05b0c..5a50014d3 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -66,7 +66,7 @@ const staticColumns = [ // TODO: add version column when API supports v6 pools colHelper.display({ header: 'IPs Remaining', - cell: (info) => , + cell: (info) => , }), colHelper.accessor('timeCreated', Columns.timeCreated), ] From e7f1920ec5d13019729d9641dd871ae111dc1a4d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:08:27 -0800 Subject: [PATCH 32/73] update tests --- app/components/form/fields/IpPoolSelector.tsx | 1 + mock-api/ip-pool.ts | 4 +-- test/e2e/floating-ip-create.e2e.ts | 18 ++++++++---- test/e2e/instance-create.e2e.ts | 28 +++++++++++++++---- test/e2e/instance-networking.e2e.ts | 2 ++ test/e2e/ip-pools.e2e.ts | 10 ++++++- test/e2e/silos.e2e.ts | 26 ++++++++++++++--- 7 files changed, 71 insertions(+), 18 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index be7e84284..976ce13d2 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -127,6 +127,7 @@ export function IpPoolSelector({ = [ ip_pool_id: ipPool5Multicast.id, range: { first: '224.0.0.1', - last: '224.0.0.20', + last: '224.0.0.32', }, time_created: new Date().toISOString(), }, @@ -159,7 +159,7 @@ export const ipPoolRanges: Json = [ ip_pool_id: ipPool6Multicast.id, range: { first: 'ff00::1', - last: 'ff00::20', + last: 'ff00::ffff:ffff:ffff:ffff', }, time_created: new Date().toISOString(), }, diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 4bedc596c..7fb7fb7f8 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -28,19 +28,27 @@ test('can create a floating IP', async ({ page }) => { .getByRole('textbox', { name: 'Description' }) .fill('A description for this Floating IP') - const label = page.getByLabel('IP pool') + const advancedAccordion = page.getByRole('button', { name: 'Advanced' }) + const poolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') // accordion content should be hidden - await expect(label).toBeHidden() + await expect(poolRadio).toBeHidden() // open accordion - await page.getByRole('button', { name: 'Advanced' }).click() + await advancedAccordion.click() // accordion content should be visible - await expect(label).toBeVisible() + await expect(poolRadio).toBeVisible() + + // select custom pool radio button + await poolRadio.click() + + // now the IP pool dropdown should be visible + await expect(poolDropdown).toBeVisible() // choose pool and submit - await label.click() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() await page.getByRole('button', { name: 'Create floating IP' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 305a678da..5d2e929dd 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -75,20 +75,36 @@ test('can create an instance', async ({ page }) => { const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const label = page.getByLabel('IP pool for ephemeral IP') + const customPoolRadio = page.getByRole('radio', { name: 'custom pool' }) + const poolDropdown = page.getByLabel('IP pool') - // verify that the ip pool selector is visible and default is selected + // verify that the ephemeral IP checkbox is checked and default radio is selected await expect(checkbox).toBeChecked() - await label.click() + // IPv4 default should be selected by default + await expect( + page.getByRole('radio', { name: 'IPv4 default', checked: true }) + ).toBeVisible() + + // select custom pool to see the dropdown + await customPoolRadio.click() + await expect(poolDropdown).toBeVisible() + await poolDropdown.click() await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled() - // unchecking the box should disable the selector + // unchecking the box should hide the pool selector await checkbox.uncheck() - await expect(label).toBeHidden() + await expect(customPoolRadio).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await selectOption(page, 'IP pool for ephemeral IP', 'ip-pool-2 default VPN IPs') + await customPoolRadio.click() + // 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 }) // should be visible in accordion await expect(page.getByRole('radiogroup', { name: 'Network interface' })).toBeVisible() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 7f41656e9..94e1ff94c 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -142,6 +142,8 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page await attachEphemeralIpButton.click() modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() + // Select custom pool radio to show the dropdown + await page.getByRole('radio', { name: 'custom pool' }).click() await page.getByRole('button', { name: 'IP pool' }).click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 8003a58fc..6c47cbaa6 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -19,7 +19,7 @@ test('IP pool list', async ({ page }) => { const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows + await expect(table.getByRole('row')).toHaveCount(7) // header + 6 rows (includes multicast pools) await expectRowVisible(table, { name: 'ip-pool-1', @@ -37,6 +37,14 @@ test('IP pool list', async ({ page }) => { name: 'ip-pool-4', 'IPs Remaining': '18.4e18 / 18.4e18', }) + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IPs Remaining': '32 / 32', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IPs Remaining': '18.4e18 / 18.4e18', + }) }) test.describe('german locale', () => { diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index ef0d7aa15..f1b5ab3c1 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -264,10 +264,19 @@ test('Silo IP pools', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - // Both pools start as default (one IPv4, one IPv6) - valid dual-default scenario + // Both unicast pools start as default (one IPv4, one IPv6) - valid dual-default scenario await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Multicast pools are also linked as defaults + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 // clicking on pool goes to pool detail await page.getByRole('link', { name: 'ip-pool-1' }).click() @@ -301,10 +310,19 @@ test('Silo IP pools link pool', async ({ page }) => { await page.goto('/system/silos/maze-war/ip-pools') const table = page.getByRole('table') - // Both pools start as default (one IPv4, one IPv6) + // Both unicast pools start as default (one IPv4, one IPv6) await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) - await expect(table.getByRole('row')).toHaveCount(3) // header + 2 + // Multicast pools are also linked + await expectRowVisible(table, { + name: 'ip-pool-5-multicast-v4', + 'IP Version': 'v4default', + }) + await expectRowVisible(table, { + name: 'ip-pool-6-multicast-v6', + 'IP Version': 'v6default', + }) + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 const modal = page.getByRole('dialog', { name: 'Link pool' }) await expect(modal).toBeHidden() From 6405314edb25647e69ae5e05083823f6206589c4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:34:47 -0800 Subject: [PATCH 33/73] Fix issue with sometimes blank field --- app/components/form/fields/IpPoolSelector.tsx | 78 +++++++++++++++---- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 976ce13d2..e58303dec 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useEffect } from 'react' import type { Control, UseFormSetValue } from 'react-hook-form' import type { IpVersion, SiloIpPool } from '@oxide/api' @@ -50,10 +51,9 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Filter pools by compatible versions for custom pool dropdown - const filteredPools = compatibleVersions - ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) - : pools + // Treat empty compatibleVersions array as "unknown" (same as undefined) + // This handles the case where NICs haven't loaded yet + const hasCompatibilityConstraints = compatibleVersions && compatibleVersions.length > 0 // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') @@ -61,17 +61,65 @@ export function IpPoolSelector({ // Filter default options by compatible versions const showV4Default = - hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) + hasV4Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v4')) const showV6Default = - hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) + hasV6Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v6')) + + // Filter pools by compatible versions for custom pool dropdown + const filteredPools = hasCompatibilityConstraints + ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) + : pools - // Derive current selection from pool and ipVersion + // Derive current selection, ensuring it maps to a rendered option type SelectionType = 'v4-default' | 'v6-default' | 'custom' - const currentSelection: SelectionType = currentPool - ? 'custom' - : currentIpVersion === 'v6' - ? 'v6-default' - : 'v4-default' + let currentSelection: SelectionType + + if (currentPool && filteredPools.some((p) => p.name === currentPool)) { + // Valid custom pool selected + currentSelection = 'custom' + } else if (!currentPool && currentIpVersion === 'v6' && showV6Default) { + // v6 default requested and available + currentSelection = 'v6-default' + } else if (!currentPool && showV4Default) { + // v4 default (explicit or fallback) + currentSelection = 'v4-default' + } else if (showV6Default) { + // Fallback to v6 default + currentSelection = 'v6-default' + } else if (filteredPools.length > 0) { + // Fallback to custom + currentSelection = 'custom' + } else { + // No options available - pick v4-default as safe default + currentSelection = 'v4-default' + } + + const radioName = `pool-selection-type-${poolFieldName}` + + // Auto-correct form state when compatibility filtering changes the selection + useEffect(() => { + if (currentSelection === 'v4-default' && (currentPool || currentIpVersion !== 'v4')) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + } else if ( + currentSelection === 'v6-default' && + (currentPool || currentIpVersion !== 'v6') + ) { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + } else if (currentSelection === 'custom' && !currentPool && filteredPools.length > 0) { + // Fell back to custom but no pool selected + setValue(poolFieldName, filteredPools[0].name) + } + }, [ + currentSelection, + currentPool, + currentIpVersion, + filteredPools, + poolFieldName, + ipVersionFieldName, + setValue, + ]) return (
@@ -80,7 +128,7 @@ export function IpPoolSelector({
{showV4Default && ( { @@ -94,7 +142,7 @@ export function IpPoolSelector({ )} {showV6Default && ( { @@ -107,7 +155,7 @@ export function IpPoolSelector({ )} { From a1a1006e616c23c37a4c4a16a60dce30a8f563a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:42:38 -0800 Subject: [PATCH 34/73] Better handling of IP versions to show during create flow --- app/forms/instance-create.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 0d0e74da9..e7a3b76e2 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -846,14 +846,18 @@ const AdvancedAccordion = ({ {/* Calculate compatible IP versions based on NIC type */} {(() => { const nicType = networkInterfaces?.type - const compatibleVersions: IpVersion[] = [] - - if (nicType === 'default_ipv4' || nicType === 'default_dual_stack') { - compatibleVersions.push('v4') - } - if (nicType === 'default_ipv6' || nicType === 'default_dual_stack') { - compatibleVersions.push('v6') + let compatibleVersions: IpVersion[] | undefined = undefined + + // Only set constraints for the default_* types + // For 'create' and 'none', leave undefined (treat as "unknown" - allow both) + if (nicType === 'default_ipv4') { + compatibleVersions = ['v4'] + } else if (nicType === 'default_ipv6') { + compatibleVersions = ['v6'] + } else if (nicType === 'default_dual_stack') { + compatibleVersions = ['v4', 'v6'] } + // nicType === 'create' or 'none': compatibleVersions stays undefined return ( <> From 7cafaf216a56e10c8a3d13001275840f175db369 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 22:52:27 -0800 Subject: [PATCH 35/73] Better IP type compatability handling --- app/components/AttachEphemeralIpModal.tsx | 18 ++++++++++++++---- app/components/form/fields/IpPoolSelector.tsx | 11 ++++------- mock-api/ip-pool.ts | 6 +++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 76f166020..c828a6501 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -45,8 +45,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Determine compatible IP versions based on instance's network interfaces // External IP version must match the NIC's private IP stack - const compatibleVersions: IpVersion[] = useMemo(() => { - if (!nics) return [] + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + // Before NICs load, return undefined (treat as "unknown" - allow all) + if (!nics) return undefined const nicItems = nics.items const hasV4Nic = nicItems.some( @@ -59,6 +60,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const versions: IpVersion[] = [] if (hasV4Nic) versions.push('v4') if (hasV6Nic) versions.push('v6') + // Return the array (could be empty if instance has no NICs with compatible stacks) return versions }, [nics]) @@ -83,7 +85,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Update ipVersion if only one version is compatible useEffect(() => { - if (compatibleVersions.length === 1) { + if (compatibleVersions && compatibleVersions.length === 1) { form.setValue('ipVersion', compatibleVersions[0]) } }, [compatibleVersions, form]) @@ -92,6 +94,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const getDisabledReason = () => { if (!siloPools) return 'Loading pools...' + if (!nics) return 'Loading network interfaces...' + if (compatibleVersions && compatibleVersions.length === 0) { + return 'Instance has no network interfaces with compatible IP stacks' + } if (unicastPools.length === 0) return 'No unicast pools available' if (!pool && !hasDefaultUnicastPool) { return 'No default pool available; select a pool to continue' @@ -121,7 +127,11 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) { diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index e58303dec..878600be1 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -51,22 +51,19 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Treat empty compatibleVersions array as "unknown" (same as undefined) - // This handles the case where NICs haven't loaded yet - const hasCompatibilityConstraints = compatibleVersions && compatibleVersions.length > 0 - // Determine which default pool versions exist const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') // Filter default options by compatible versions + // undefined = no filtering, [] = filter out everything const showV4Default = - hasV4Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v4')) + hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) const showV6Default = - hasV6Default && (!hasCompatibilityConstraints || compatibleVersions.includes('v6')) + hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) // Filter pools by compatible versions for custom pool dropdown - const filteredPools = hasCompatibilityConstraints + const filteredPools = compatibleVersions ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) : pools diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 3ed866731..f8544be7a 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -63,7 +63,7 @@ export const ipPool5Multicast: Json = { } export const ipPool6Multicast: Json = { - id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849fg2e', + id: 'c7d5b7ca-872f-4e39-95d1-fe4e8849f02e', name: 'ip-pool-6-multicast-v6', description: 'Multicast v6 pool', time_created: new Date().toISOString(), @@ -146,7 +146,7 @@ export const ipPoolRanges: Json = [ }, // Multicast pool ranges (should NOT be used for ephemeral/floating IPs) { - id: 'e8f6c8db-983g-4f4a-a6e2-gf5f9960gh3f', + id: 'e8f6c8db-9830-4f4a-a6e2-0f5f99600b3f', ip_pool_id: ipPool5Multicast.id, range: { first: '224.0.0.1', @@ -155,7 +155,7 @@ export const ipPoolRanges: Json = [ time_created: new Date().toISOString(), }, { - id: 'f9g7d9ec-a94h-5g5b-b7f3-hg6ga071hi4g', + id: 'f9a7d9ec-a940-5a5b-b7f3-0a6aa0710b4a', ip_pool_id: ipPool6Multicast.id, range: { first: 'ff00::1', From 0c75dd6139cb4b7776ef7773d1f58199d9187d0c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 23:02:29 -0800 Subject: [PATCH 36/73] Better default-pool + ipVersion handling --- app/components/AttachEphemeralIpModal.tsx | 17 ++++++++++++++++- app/forms/floating-ip-create.tsx | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index c828a6501..cd033bdcf 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -135,12 +135,27 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) } disabledReason={getDisabledReason()} onAction={() => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + instanceEphemeralIpAttach.mutate({ path: { instance }, query: { project }, body: pool ? { poolSelector: { type: 'explicit', pool } } - : { poolSelector: { type: 'auto', ipVersion } }, + : { poolSelector: { type: 'auto', ipVersion: effectiveIpVersion } }, }) }} onDismiss={onDismiss} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index d545023bf..12657d25d 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -86,6 +86,21 @@ export default function CreateFloatingIpSideModalForm() { resourceName="floating IP" onDismiss={() => navigate(pb.floatingIps(projectSelector))} onSubmit={({ pool, ipVersion, ...values }) => { + // When using default pool, derive ipVersion from available defaults + let effectiveIpVersion = ipVersion + if (!pool) { + const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') + const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + + // If only one default exists, use that version + if (v4Default && !v6Default) { + effectiveIpVersion = 'v4' + } else if (v6Default && !v4Default) { + effectiveIpVersion = 'v6' + } + // If both exist, use form's ipVersion (user's choice) + } + const body: FloatingIpCreate = { ...values, addressAllocator: pool @@ -95,7 +110,7 @@ export default function CreateFloatingIpSideModalForm() { } : { type: 'auto' as const, - poolSelector: { type: 'auto' as const, ipVersion }, + poolSelector: { type: 'auto' as const, ipVersion: effectiveIpVersion }, }, } createFloatingIp.mutate({ query: projectSelector, body }) From 909b19698b4ba53acd8901d96101014cf8fb178b Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 22 Jan 2026 23:09:44 -0800 Subject: [PATCH 37/73] Better handle defaults and empty states --- app/components/form/fields/IpPoolSelector.tsx | 96 ++++++++++--------- 1 file changed, 52 insertions(+), 44 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 878600be1..bab09147e 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -81,16 +81,18 @@ export function IpPoolSelector({ // v4 default (explicit or fallback) currentSelection = 'v4-default' } else if (showV6Default) { - // Fallback to v6 default + // Fallback to v6 default if rendered currentSelection = 'v6-default' - } else if (filteredPools.length > 0) { - // Fallback to custom - currentSelection = 'custom' - } else { - // No options available - pick v4-default as safe default + } else if (showV4Default) { + // Fallback to v4 default if rendered currentSelection = 'v4-default' + } else { + // Final fallback: custom radio is always rendered, so this is safe + currentSelection = 'custom' } + const hasNoPools = filteredPools.length === 0 && !showV4Default && !showV6Default + const radioName = `pool-selection-type-${poolFieldName}` // Auto-correct form state when compatibility filtering changes the selection @@ -122,53 +124,59 @@ export function IpPoolSelector({
Select IP pool -
- {showV4Default && ( - { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v4') - }} - disabled={disabled} - > - IPv4 default - - )} - {showV6Default && ( + {hasNoPools ? ( +
+ No IP pools available for this network interface type +
+ ) : ( +
+ {showV4Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v4') + }} + disabled={disabled} + > + IPv4 default + + )} + {showV6Default && ( + { + setValue(poolFieldName, '') + setValue(ipVersionFieldName, 'v6') + }} + disabled={disabled} + > + IPv6 default + + )} { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v6') + // Set to first compatible pool in list so the dropdown shows with a valid selection + if (filteredPools.length > 0) { + setValue(poolFieldName, filteredPools[0].name) + } }} disabled={disabled} > - IPv6 default + custom pool - )} - { - // Set to first compatible pool in list so the dropdown shows with a valid selection - if (filteredPools.length > 0) { - setValue(poolFieldName, filteredPools[0].name) - } - }} - disabled={disabled} - > - custom pool - -
+
+ )}
- {currentSelection === 'custom' && ( + {currentSelection === 'custom' && filteredPools.length > 0 && ( Date: Fri, 23 Jan 2026 08:00:45 -0800 Subject: [PATCH 38/73] refactor / copy --- app/components/form/fields/IpPoolSelector.tsx | 2 +- app/forms/instance-create.tsx | 24 +++++++------------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index bab09147e..0a9adba69 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -170,7 +170,7 @@ export function IpPoolSelector({ }} disabled={disabled} > - custom pool + select pool
)} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e7a3b76e2..e68de5d40 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -239,22 +239,16 @@ export default function CreateInstanceForm() { [siloPools] ) + const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) + const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') + const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') + // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = useMemo(() => { - const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - return hasV4Default && hasV6Default - }, [unicastPools]) + const hasDualDefaults = hasV4Default && hasV6Default const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' - // Calculate if there's a single default pool (not dual defaults) - const singleDefaultPool = !hasDualDefaults - ? unicastPools.find((p) => p.isDefault)?.name - : undefined - const defaultValues: InstanceCreateInput = { ...baseDefaultValues, bootDiskSourceType: defaultSource, @@ -264,10 +258,10 @@ export default function CreateInstanceForm() { ephemeralIpVersion: 'v4', // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") ephemeralIpPool: '', - externalIps: singleDefaultPool - ? [{ type: 'ephemeral', poolSelector: { type: 'explicit', pool: singleDefaultPool } }] - : hasDualDefaults - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + externalIps: hasV4Default + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] + : hasV6Default + ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v6' } }] : [{ type: 'ephemeral' }], } From e3d52c8f99cd525cd3950c1fb1e276b5787e4303 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 09:52:00 -0800 Subject: [PATCH 39/73] another refactor to defaults to match API --- app/forms/instance-create.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e68de5d40..a7166fd22 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -245,6 +245,8 @@ export default function CreateInstanceForm() { // Detect if both IPv4 and IPv6 default unicast pools exist const hasDualDefaults = hasV4Default && hasV6Default + // Use default version if available; fall back to v4 + const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' @@ -254,15 +256,22 @@ export default function CreateInstanceForm() { bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), - // When dual defaults exist and no explicit pool, default to v4 for dual_stack - ephemeralIpVersion: 'v4', - // Set ephemeralIpPool if there's a single default, otherwise leave empty (for radio "use default") + ephemeralIpVersion, + // Set ephemeralIpPool empty (for radio "use default") ephemeralIpPool: '', - externalIps: hasV4Default - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v4' } }] - : hasV6Default - ? [{ type: 'ephemeral', poolSelector: { type: 'auto', ipVersion: 'v6' } }] - : [{ type: 'ephemeral' }], + // API behavior: + // - Single default: { type: 'ephemeral' } → API auto-picks the one default + // - Dual defaults: { poolSelector: { type: 'auto', ipVersion } } → Must specify version + // right now we default to 'v4' when both exist + // - No defaults: { type: 'ephemeral' } → Will fail, but user will see error + externalIps: hasDualDefaults + ? [ + { + type: 'ephemeral', + poolSelector: { type: 'auto', ipVersion: 'v4' }, + }, + ] + : [{ type: 'ephemeral' }], } const form = useForm({ defaultValues }) From 8d211a118dbecd8d3b40e1ed5748eb9594ef32a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 12:35:20 -0800 Subject: [PATCH 40/73] revert copy for now --- app/components/form/fields/IpPoolSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 0a9adba69..bab09147e 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -170,7 +170,7 @@ export function IpPoolSelector({ }} disabled={disabled} > - select pool + custom pool
)} From f0ee1c77cc56e036695eac0fb90dd60d63746f00 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 15:15:13 -0800 Subject: [PATCH 41/73] external IP version compatibility should consider the primary NIC; add tests for Ephemeral IP attachment --- app/components/AttachEphemeralIpModal.tsx | 30 ++-- mock-api/msw/handlers.ts | 38 +++++ test/e2e/instance-networking.e2e.ts | 191 ++++++++++++++++++++++ 3 files changed, 248 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index cd033bdcf..f955760a8 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -43,24 +43,32 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return unicastPools.some((p) => p.isDefault) }, [unicastPools]) - // Determine compatible IP versions based on instance's network interfaces - // External IP version must match the NIC's private IP stack + // Determine compatible IP versions based on instance's primary network interface + // External IPs route through the primary interface, so only its IP stack matters + // https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L544-L661 const compatibleVersions: IpVersion[] | undefined = useMemo(() => { // Before NICs load, return undefined (treat as "unknown" - allow all) if (!nics) return undefined const nicItems = nics.items - const hasV4Nic = nicItems.some( - (nic) => nic.ipStack.type === 'v4' || nic.ipStack.type === 'dual_stack' - ) - const hasV6Nic = nicItems.some( - (nic) => nic.ipStack.type === 'v6' || nic.ipStack.type === 'dual_stack' - ) + const primaryNic = nicItems.find((nic) => nic.primary) + + // If no primary NIC found (defensive), return empty array + if (!primaryNic) return [] const versions: IpVersion[] = [] - if (hasV4Nic) versions.push('v4') - if (hasV6Nic) versions.push('v6') - // Return the array (could be empty if instance has no NICs with compatible stacks) + if ( + primaryNic.ipStack.type === 'v4' || + primaryNic.ipStack.type === 'dual_stack' + ) { + versions.push('v4') + } + if ( + primaryNic.ipStack.type === 'v6' || + primaryNic.ipStack.type === 'dual_stack' + ) { + versions.push('v6') + } return versions }, [nics]) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f43154375..c7fb6c59e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -861,6 +861,44 @@ export const handlers = makeHandlers({ const pool = resolvePoolSelector(body.pool_selector, 'unicast') const ip = getIpFromPool(pool) + // Validate that external IP version matches primary NIC's IP stack + // Based on Omicron validation in nexus/db-queries/src/db/datastore/external_ip.rs:544-661 + const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id) + const primaryNic = nics.find((n) => n.primary) + + if (!primaryNic) { + throw json( + { + error_code: 'InvalidRequest', + message: `Instance ${instance.name} has no primary network interface`, + }, + { status: 400 } + ) + } + + const ipVersion = pool.ip_version + const stackType = primaryNic.ip_stack.type + + if (ipVersion === 'v4' && stackType !== 'v4' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv4 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv4 address. Add a VPC-private IPv4 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + + if (ipVersion === 'v6' && stackType !== 'v6' && stackType !== 'dual_stack') { + throw json( + { + error_code: 'InvalidRequest', + message: `The ephemeral external IP is an IPv6 address, but the instance with ID ${instance.name} does not have a primary network interface with a VPC-private IPv6 address. Add a VPC-private IPv6 address to the interface, or attach a different IP address`, + }, + { status: 400 } + ) + } + const externalIp = { ip, ip_pool_id: pool.id, kind: 'ephemeral' as const } db.ephemeralIps.push({ instance_id: instance.id, diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 94e1ff94c..bcb120dc4 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -13,8 +13,15 @@ import { expectRowVisible, expectVisible, stopInstance, + type Page, } from './utils' +const selectASiloImage = async (page: Page, name: string) => { + await page.getByRole('tab', { name: 'Silo images' }).click() + await page.getByPlaceholder('Select a silo image', { exact: true }).click() + await page.getByRole('option', { name }).click() +} + test('Instance networking tab — NIC table', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') @@ -294,3 +301,187 @@ test('Edit network interface - Transit IPs', async ({ page }) => { page.getByRole('tooltip', { name: 'Other transit IPs 192.168.0.0/16 ::/64' }) ).toBeVisible() }) + +test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Verify that IPv6 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Verify IPv4 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() + + // ip-pool-1 is IPv4, should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() +}) + +test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-only-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-only-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Try to attach ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Verify that IPv4 default radio is NOT shown (filtered out by compatibility check) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Verify IPv6 default radio IS shown + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should not appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() + + // ip-pool-2 is IPv6, should appear + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) + +test('IPv4-only instance can attach IPv4 ephemeral IP', async ({ page }) => { + // Create an IPv4-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv4-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv4-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv4-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv4 ephemeral IP (using default pool) + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Use default IPv4 pool + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) + +test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { + // Create an IPv6-only instance + await page.goto('/projects/mock-project/instances-new') + const instanceName = 'ipv6-success-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion and select IPv6-only + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Don't attach ephemeral IP at creation + await page + .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' }) + .uncheck() + + // Create instance + await page.getByRole('button', { name: 'Create instance' }).click() + await expect(page).toHaveURL(/\/instances\/ipv6-success-test/) + + // Navigate to networking tab + await page.getByRole('tab', { name: 'Networking' }).click() + + // Attach IPv6 ephemeral IP + const attachButton = page.getByRole('button', { name: 'Attach ephemeral IP' }) + await expect(attachButton).toBeEnabled() + await attachButton.click() + + const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) + await expect(modal).toBeVisible() + + // Select IPv6 pool (ip-pool-2) + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + await page.getByRole('option', { name: 'ip-pool-2' }).click() + + await page.getByRole('button', { name: 'Attach', exact: true }).click() + + // Modal should close and IP should be attached + await expect(modal).toBeHidden() + + // Verify ephemeral IP appears in table + const externalIpTable = page.getByRole('table', { name: 'External IPs' }) + await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible() +}) From 8645fa1cf4c681b0427d089fe04917d15d49329c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 23 Jan 2026 15:28:08 -0800 Subject: [PATCH 42/73] Ensure that when custom NIC is created, Ephemeral IP options match first custom NIC in list --- app/components/AttachEphemeralIpModal.tsx | 10 +- app/forms/instance-create.tsx | 22 ++- test/e2e/instance-create.e2e.ts | 182 ++++++++++++++++++++++ 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index f955760a8..31454857c 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -57,16 +57,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) if (!primaryNic) return [] const versions: IpVersion[] = [] - if ( - primaryNic.ipStack.type === 'v4' || - primaryNic.ipStack.type === 'dual_stack' - ) { + if (primaryNic.ipStack.type === 'v4' || primaryNic.ipStack.type === 'dual_stack') { versions.push('v4') } - if ( - primaryNic.ipStack.type === 'v6' || - primaryNic.ipStack.type === 'dual_stack' - ) { + if (primaryNic.ipStack.type === 'v6' || primaryNic.ipStack.type === 'dual_stack') { versions.push('v6') } return versions diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a7166fd22..fc1534299 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -851,16 +851,32 @@ const AdvancedAccordion = ({ const nicType = networkInterfaces?.type let compatibleVersions: IpVersion[] | undefined = undefined - // Only set constraints for the default_* types - // For 'create' and 'none', leave undefined (treat as "unknown" - allow both) + // Set constraints based on primary NIC configuration if (nicType === 'default_ipv4') { compatibleVersions = ['v4'] } else if (nicType === 'default_ipv6') { compatibleVersions = ['v6'] } else if (nicType === 'default_dual_stack') { compatibleVersions = ['v4', 'v6'] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length > 0 + ) { + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') { + compatibleVersions = ['v4'] + } else if (primaryNicConfig?.type === 'v6') { + compatibleVersions = ['v6'] + } else if (primaryNicConfig?.type === 'dual_stack') { + compatibleVersions = ['v4', 'v6'] + } else { + // ipConfig not provided = defaults to dual-stack + compatibleVersions = ['v4', 'v6'] + } } - // nicType === 'create' or 'none': compatibleVersions stays undefined + // nicType === 'none': compatibleVersions stays undefined (instance has no NICs) return ( <> diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 5d2e929dd..b7f782417 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -801,3 +801,185 @@ test('create instance with dual-stack networking shows both IPs', async ({ page expect(cellText).toMatch(/127\.0\.0\.1/) // IPv4 expect(cellText).toMatch(/::1/) // IPv6 }) + +test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv4-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv4-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4-only IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + 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', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // IPv4 default should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + + // IPv6 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + + // Check custom pool - IPv6 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-1 is IPv4, should appear + 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 ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-ipv6-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with IPv6-only configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-ipv6-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv6-only IP configuration + await modal.getByRole('radio', { name: 'IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + 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', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // IPv6 default should be available + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // IPv4 default should NOT be available (filtered out) + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + + // Check custom pool - IPv4 pools should be filtered out + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // ip-pool-2 is IPv6, should appear + 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 ({ + page, +}) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'custom-dual-stack-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "Custom" network interface (use exact match and first to disambiguate from "custom pool") + await page.getByRole('radio', { name: 'Custom', exact: true }).first().click() + + // Add a custom NIC with dual-stack configuration + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + await modal.getByRole('textbox', { name: 'Name' }).fill('my-dual-stack-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select dual-stack IP configuration (should be default) + await modal.getByRole('radio', { name: 'IPv4 & IPv6', exact: true }).click() + + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + 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', + }) + await expect(ephemeralCheckbox).toBeVisible() + + // Both IPv4 and IPv6 defaults should be available + await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + + // Check custom pool - both IPv4 and IPv6 pools should be available + await page.getByRole('radio', { name: 'custom pool' }).click() + await page.getByRole('button', { name: 'IP pool' }).click() + + // Both pools should appear + await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() +}) From c9a792b125d78eafce070f3641823a25b147e8f3 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 09:53:38 -0800 Subject: [PATCH 43/73] Disable ephemeral IP checkbox when instance has no compatible NICs --- app/forms/instance-create.tsx | 148 ++++++++++++++++++++++---------- test/e2e/instance-create.e2e.ts | 82 ++++++++++++++++++ 2 files changed, 186 insertions(+), 44 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index fc1534299..9054350d9 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ import * as Accordion from '@radix-ui/react-accordion' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useController, useForm, @@ -83,6 +83,8 @@ import { Slash } from '~/ui/lib/Slash' import { Tabs } from '~/ui/lib/Tabs' import { TextInputHint } from '~/ui/lib/TextInput' import { TipIcon } from '~/ui/lib/TipIcon' +import { Tooltip } from '~/ui/lib/Tooltip' +import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' import { docLinks, links } from '~/util/links' @@ -783,6 +785,87 @@ const AdvancedAccordion = ({ .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) .filter((ip) => !!ip) + // Calculate compatible IP versions based on NIC type + const compatibleVersions: IpVersion[] | undefined = useMemo(() => { + const nicType = networkInterfaces?.type + let versions: IpVersion[] | undefined = undefined + + // Set constraints based on primary NIC configuration + if (nicType === 'default_ipv4') { + versions = ['v4'] + } else if (nicType === 'default_ipv6') { + versions = ['v6'] + } else if (nicType === 'default_dual_stack') { + versions = ['v4', 'v6'] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length > 0 + ) { + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') { + versions = ['v4'] + } else if (primaryNicConfig?.type === 'v6') { + versions = ['v6'] + } else if (primaryNicConfig?.type === 'dual_stack') { + versions = ['v4', 'v6'] + } else { + // ipConfig not provided = defaults to dual-stack + versions = ['v4', 'v6'] + } + } else if (nicType === 'none') { + // Instance has no NICs, cannot attach external IPs + versions = [] + } else if ( + nicType === 'create' && + networkInterfaces && + networkInterfaces.params.length === 0 + ) { + // Custom NICs selected but none added yet, cannot attach external IPs + versions = [] + } + + return versions + }, [networkInterfaces]) + + // Track previous compatible NICs state to detect transitions + const prevHasNicsRef = useRef(undefined) + + // Automatically manage ephemeral IP based on NIC availability + useEffect(() => { + const hasNics = compatibleVersions && compatibleVersions.length > 0 + const prevHasNics = prevHasNicsRef.current + + if (!hasNics && assignEphemeralIp) { + // Remove ephemeral IP when there are no compatible NICs + const newExternalIps = externalIps.field.value?.filter( + (ip) => ip.type !== 'ephemeral' + ) + externalIps.field.onChange(newExternalIps) + } else if (hasNics && !prevHasNics && !assignEphemeralIp) { + // Add ephemeral IP only when transitioning from no NICs to having NICs + // (prevHasNics === false means we had no NICs before) + externalIps.field.onChange([ + ...(externalIps.field.value || []), + { type: 'ephemeral' }, + ]) + } + + prevHasNicsRef.current = hasNics + }, [compatibleVersions, assignEphemeralIp, externalIps]) + + // Update ephemeralIpVersion when compatibleVersions changes + useEffect(() => { + if (!compatibleVersions || compatibleVersions.length === 0) return + + const currentVersion = ephemeralIpVersionField.field.value + // If current version is not compatible, switch to the first compatible version + if (!compatibleVersions.includes(currentVersion)) { + setValue('ephemeralIpVersion', compatibleVersions[0]) + } + }, [compatibleVersions, ephemeralIpVersionField.field.value, setValue]) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -846,53 +929,30 @@ const AdvancedAccordion = ({ - {/* Calculate compatible IP versions based on NIC type */} {(() => { - const nicType = networkInterfaces?.type - let compatibleVersions: IpVersion[] | undefined = undefined - - // Set constraints based on primary NIC configuration - if (nicType === 'default_ipv4') { - compatibleVersions = ['v4'] - } else if (nicType === 'default_ipv6') { - compatibleVersions = ['v6'] - } else if (nicType === 'default_dual_stack') { - compatibleVersions = ['v4', 'v6'] - } else if ( - nicType === 'create' && - networkInterfaces && - networkInterfaces.params.length > 0 - ) { - // Derive from the first NIC's ipConfig (first NIC becomes primary) - const primaryNicConfig = networkInterfaces.params[0].ipConfig - if (primaryNicConfig?.type === 'v4') { - compatibleVersions = ['v4'] - } else if (primaryNicConfig?.type === 'v6') { - compatibleVersions = ['v6'] - } else if (primaryNicConfig?.type === 'dual_stack') { - compatibleVersions = ['v4', 'v6'] - } else { - // ipConfig not provided = defaults to dual-stack - compatibleVersions = ['v4', 'v6'] - } - } - // nicType === 'none': compatibleVersions stays undefined (instance has no NICs) + const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 + const disabledReason = hasCompatibleNics + ? undefined + : 'Add a compatible network interface to attach an ephemeral IP address' return ( <> - { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [...(externalIps.field.value || []), { type: 'ephemeral' }] - externalIps.field.onChange(newExternalIps) - // The useEffect will update the poolSelector based on current form values - }} - > - Allocate and attach an ephemeral IP address - + }> + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + {assignEphemeralIp && ( { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'ephemeral-ip-nic-test' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + const ephemeralCheckbox = page.getByRole('checkbox', { + name: 'Allocate and attach an ephemeral IP address', + }) + const defaultDualStackRadio = page.getByRole('radio', { + name: 'Default IPv4 & IPv6', + exact: true, + }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }).first() + + // Verify default state: "Default IPv4 & IPv6" is checked and Ephemeral IP checkbox is checked + await expect(defaultDualStackRadio).toBeChecked() + await expect(ephemeralCheckbox).toBeChecked() + await expect(ephemeralCheckbox).toBeEnabled() + + // Select "None" radio → verify Ephemeral IP checkbox is unchecked and disabled + 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 to attach an ephemeral IP address') + ).toBeVisible() + + // Select "Custom" radio → verify Ephemeral IP checkbox is still unchecked and disabled + await customRadio.click() + await expect(ephemeralCheckbox).not.toBeChecked() + await expect(ephemeralCheckbox).toBeDisabled() + + // Click "Add network interface" button to open modal + await page.getByRole('button', { name: 'Add network interface' }).click() + + const modal = page.getByRole('dialog', { name: 'Add network interface' }) + await expect(modal).toBeVisible() + + // Create an IPv4 NIC named "new-v4-nic" + await modal.getByRole('textbox', { name: 'Name' }).fill('new-v4-nic') + await modal.getByLabel('VPC', { exact: true }).click() + await page.getByRole('option', { name: 'mock-vpc' }).click() + await modal.getByLabel('Subnet').click() + await page.getByRole('option', { name: 'mock-subnet' }).click() + + // Select IPv4 IP configuration + await modal.getByRole('radio', { name: 'IPv4', exact: true }).click() + + // Submit the modal + await modal.getByRole('button', { name: 'Add network interface' }).click() + await expect(modal).toBeHidden() + + // Verify the NIC was added to the table + const nicTable = page.getByRole('table', { name: 'Network Interfaces' }) + await expect( + 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() + + // Delete the NIC using the remove button + await page.getByRole('button', { name: 'remove network interface new-v4-nic' }).click() + + // 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() +}) From a2e5b8b727c47a321f527ef44dcaafd39dba3083 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 10:10:24 -0800 Subject: [PATCH 44/73] Add IP version to silo IP Pools table --- app/pages/project/instances/NetworkingTab.tsx | 6 ++++++ app/pages/system/networking/IpPoolsPage.tsx | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index f3a558d83..b5e1226fc 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -44,6 +44,7 @@ import { addToast } from '~/stores/toast' import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { IpVersionCell } from '~/table/cells/IpVersionCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -238,6 +239,11 @@ const staticIpCols = [ ), cell: (info) => {info.getValue()}, }), + ipColHelper.accessor('ipPoolId', { + id: 'version', + header: 'Version', + cell: (info) => , + }), ipColHelper.accessor('ipPoolId', { header: 'IP pool', cell: (info) => , diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 5a50014d3..87f926a08 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -59,11 +59,14 @@ const colHelper = createColumnHelper() const staticColumns = [ colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => {info.getValue()}, + }), colHelper.accessor('poolType', { header: 'Pool type', cell: (info) => {info.getValue()}, }), - // TODO: add version column when API supports v6 pools colHelper.display({ header: 'IPs Remaining', cell: (info) => , From 043a9daf593683aa582d7a47961463e33e8e0f28 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 11:50:16 -0800 Subject: [PATCH 45/73] Add IP version to IP Pool create flow --- app/forms/ip-pool-create.tsx | 20 ++++-- app/forms/ip-pool-range-add.tsx | 103 ++++++++++++++++++++---------- app/table/cells/IpVersionCell.tsx | 24 +++++++ test/e2e/ip-pools.e2e.ts | 63 ++++++++++++++---- 4 files changed, 155 insertions(+), 55 deletions(-) create mode 100644 app/table/cells/IpVersionCell.tsx diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index ef65c3d58..7fbec7c39 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -21,11 +21,7 @@ import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' -// We are leaving the v4/v6 radio out for now because while you can -// create a v6 pool, you can't actually add any ranges to it until -// https://github.com/oxidecomputer/omicron/issues/8966 - -type IpPoolCreateForm = SetRequired +type IpPoolCreateForm = SetRequired const defaultValues: IpPoolCreateForm = { name: '', @@ -58,8 +54,8 @@ export default function CreateIpPoolSideModalForm() { formType="create" resourceName="IP pool" onDismiss={onDismiss} - onSubmit={({ name, description, poolType }) => { - createPool.mutate({ body: { name, description, poolType } }) + onSubmit={({ name, description, ipVersion, poolType }) => { + createPool.mutate({ body: { name, description, ipVersion, poolType } }) }} loading={createPool.isPending} submitError={createPool.error} @@ -67,6 +63,16 @@ export default function CreateIpPoolSideModalForm() { + = {} - - if (first.type === 'error') { - errors.first = { type: 'pattern', message: first.message } - } else if (first.type === 'v6') { - errors.first = ipv6Error - } - - if (last.type === 'error') { - errors.last = { type: 'pattern', message: last.message } - } else if (last.type === 'v6') { - errors.last = ipv6Error +function createResolver(poolVersion: IpVersion) { + return (values: IpRange) => { + const first = parseIp(values.first) + const last = parseIp(values.last) + + const errors: FieldErrors = {} + + // Validate first address + if (first.type === 'error') { + errors.first = { type: 'pattern', message: first.message } + } else if (first.type === 'v4' && poolVersion === 'v6') { + errors.first = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (first.type === 'v6' && poolVersion === 'v4') { + errors.first = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // Validate last address + if (last.type === 'error') { + errors.last = { type: 'pattern', message: last.message } + } else if (last.type === 'v4' && poolVersion === 'v6') { + errors.last = { + type: 'pattern', + message: 'IPv4 address not allowed in IPv6 pool', + } + } else if (last.type === 'v6' && poolVersion === 'v4') { + errors.last = { + type: 'pattern', + message: 'IPv6 address not allowed in IPv4 pool', + } + } + + // Check that both addresses are the same version + if (first.type !== 'error' && last.type !== 'error' && first.type !== last.type) { + const versionMismatchError = { + type: 'pattern', + message: 'Both addresses must be the same IP version', + } + errors.first = versionMismatchError + errors.last = versionMismatchError + } + + // TODO: if we were really cool we could check first <= last but it would add + // 6k gzipped to the bundle with ip-num + + // no errors + return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } - - // TODO: once we support IPv6 we need to check for version mismatch here - - // TODO: if we were really cool we could check first <= last but it would add - // 6k gzipped to the bundle with ip-num - - // no errors - return Object.keys(errors).length > 0 ? { values: {}, errors } : { values, errors: {} } } export const handle = titleCrumb('Add Range') @@ -66,6 +98,9 @@ export default function IpPoolAddRange() { const { pool } = useIpPoolSelector() const navigate = useNavigate() + const { data: poolData } = usePrefetchedQuery(q(api.ipPoolView, { path: { pool } })) + const poolVersion = poolData.ipVersion + const onDismiss = () => navigate(pb.ipPool({ pool })) const addRange = useApiMutation(api.ipPoolRangeAdd, { @@ -78,7 +113,7 @@ export default function IpPoolAddRange() { }, }) - const form = useForm({ defaultValues, resolver }) + const form = useForm({ defaultValues, resolver: createResolver(poolVersion) }) return ( { + const { data: result } = useQuery( + qErrorsAllowed(api.projectIpPoolView, { path: { pool: ipPoolId } }) + ) + if (!result) return + if (result.type === 'error') return + const pool = result.data + return {pool.ipVersion} +} diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 6c47cbaa6..edf56f541 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -212,22 +212,17 @@ test('IP pool edit', async ({ page }) => { // TODO: update this to reflect that a given pool is now v4 or v6 only test('IP range validation and add', async ({ page }) => { - await page.goto('/system/networking/ip-pools/ip-pool-2') - - // check the utilization bar - await expect(page.getByText('Allocated(IPs)')).toBeVisible() - await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity32')).toBeVisible() + await page.goto('/system/networking/ip-pools/ip-pool-3') - await page.getByRole('link', { name: 'Add range' }).click() + await page.getByRole('link', { name: 'Add range' }).first().click() const dialog = page.getByRole('dialog', { name: 'Add IP range' }) const first = dialog.getByRole('textbox', { name: 'First' }) const last = dialog.getByRole('textbox', { name: 'Last' }) const submit = dialog.getByRole('button', { name: 'Add IP range' }) const invalidMsg = dialog.getByText('Not a valid IP address') - // exact to differentiate from same text in help message at the top of the form - const ipv6Msg = dialog.getByText('IPv6 ranges are not yet supported') + // ip-pool-3 is an IPv4 pool, so IPv6 addresses should be rejected + const ipv6Msg = dialog.getByText('IPv6 address not allowed in IPv4 pool') const v4Addr = '192.1.2.3' const v6Addr = '2001:db8::1234:5678' @@ -240,12 +235,12 @@ test('IP range validation and add', async ({ page }) => { await expect(invalidMsg).toHaveCount(2) - // change last to v6, not allowed + // change last to v6, not allowed in IPv4 pool await last.fill(v6Addr) await expect(invalidMsg).toHaveCount(1) await expect(ipv6Msg).toHaveCount(1) - // change first to v6, still not allowed + // change first to v6, still not allowed in IPv4 pool await first.fill(v6Addr) await expect(ipv6Msg).toHaveCount(2) await expect(invalidMsg).toBeHidden() @@ -265,18 +260,58 @@ test('IP range validation and add', async ({ page }) => { // now the utilization bar shows the single IP added await expect(page.getByText('Allocated(IPs)')).toBeVisible() await expect(page.getByText('Allocated0')).toBeVisible() - await expect(page.getByText('Capacity33')).toBeVisible() + await expect(page.getByText('Capacity1')).toBeVisible() // go back to the pool and verify the remaining/capacity columns changed // use the sidebar nav to get there const sidebar = page.getByRole('navigation', { name: 'Sidebar navigation' }) await sidebar.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { - name: 'ip-pool-2', - 'IPs Remaining': '33 / 33', + name: 'ip-pool-3', + 'IPs Remaining': '1 / 1', }) }) +test('IPv4 addresses cannot be added to IPv6 pool', async ({ page }) => { + // ip-pool-4 is an IPv6 pool + await page.goto('/system/networking/ip-pools/ip-pool-4') + + await page.getByRole('link', { name: 'Add range' }).first().click() + + const dialog = page.getByRole('dialog', { name: 'Add IP range' }) + const first = dialog.getByRole('textbox', { name: 'First' }) + const last = dialog.getByRole('textbox', { name: 'Last' }) + const submit = dialog.getByRole('button', { name: 'Add IP range' }) + // ip-pool-4 is an IPv6 pool, so IPv4 addresses should be rejected + const ipv4Msg = dialog.getByText('IPv4 address not allowed in IPv6 pool') + + const v4Addr = '192.168.1.1' + const v6Addr = 'fd12:3456:789a:1::1' + + await expect(dialog).toBeVisible() + + // Try to add IPv4 address - should be rejected + await first.fill(v4Addr) + await last.fill(v4Addr) + await submit.click() // trigger validation + await expect(ipv4Msg).toHaveCount(2) + + // Change first to v6 + await first.fill(v6Addr) + await expect(ipv4Msg).toHaveCount(1) + + // Change last to v6 - should now be valid + await last.fill(v6Addr) + await expect(ipv4Msg).toBeHidden() + + // Submit successfully + await submit.click() + await expect(dialog).toBeHidden() + + const table = page.getByRole('table') + await expectRowVisible(table, { First: v6Addr, Last: v6Addr }) +}) + test('remove range', async ({ page }) => { await page.goto('/system/networking/ip-pools/ip-pool-1') From 01fc260b6ca62075a35d7c9e3776fcbb7f4e7b8c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 14:10:20 -0800 Subject: [PATCH 46/73] Proper height for NIC table rows --- app/pages/project/instances/NetworkingTab.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index b5e1226fc..ec1cb90a1 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -162,7 +162,7 @@ const staticCols = [ if (ipStack.type === 'dual_stack') { return ( -
+
@@ -587,6 +587,7 @@ export default function NetworkingTab() { aria-labelledby="nics-label" table={tableInstance} className="table-inline" + rowHeight="large" /> ) : ( From 4d50fffbb061272d676da2239d142da3ca16727c Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 16:53:28 -0800 Subject: [PATCH 47/73] Better microcopy, tooltip, validation on IP Pool ranges --- app/forms/instance-create.tsx | 10 +++++++--- app/forms/ip-pool-range-add.tsx | 17 +++-------------- app/forms/vpc-router-route-common.tsx | 6 +++--- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 9054350d9..e908245be 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -931,9 +931,13 @@ const AdvancedAccordion = ({ {(() => { const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 - const disabledReason = hasCompatibleNics - ? undefined - : 'Add a compatible network interface to attach an ephemeral IP address' + const disabledReason = hasCompatibleNics ? undefined : ( + <> + Add a compatible network interface +
+ to attach an ephemeral IP address + + ) return ( <> diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index 3d8c0c5de..ee345ad19 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -34,8 +34,7 @@ const defaultValues: IpRange = { /** * Validates IP range addresses against the pool's IP version. - * Ensures both addresses are valid IPs, match the pool's version, - * and are the same version as each other. + * Ensures both addresses are valid IPs and match the pool's version. */ function createResolver(poolVersion: IpVersion) { return (values: IpRange) => { @@ -44,7 +43,7 @@ function createResolver(poolVersion: IpVersion) { const errors: FieldErrors = {} - // Validate first address + // Validate first address matches pool version if (first.type === 'error') { errors.first = { type: 'pattern', message: first.message } } else if (first.type === 'v4' && poolVersion === 'v6') { @@ -59,7 +58,7 @@ function createResolver(poolVersion: IpVersion) { } } - // Validate last address + // Validate last address matches pool version if (last.type === 'error') { errors.last = { type: 'pattern', message: last.message } } else if (last.type === 'v4' && poolVersion === 'v6') { @@ -74,16 +73,6 @@ function createResolver(poolVersion: IpVersion) { } } - // Check that both addresses are the same version - if (first.type !== 'error' && last.type !== 'error' && first.type !== last.type) { - const versionMismatchError = { - type: 'pattern', - message: 'Both addresses must be the same IP version', - } - errors.first = versionMismatchError - errors.last = versionMismatchError - } - // TODO: if we were really cool we could check first <= last but it would add // 6k gzipped to the bundle with ip-num diff --git a/app/forms/vpc-router-route-common.tsx b/app/forms/vpc-router-route-common.tsx index ee2c218f2..2167f99fc 100644 --- a/app/forms/vpc-router-route-common.tsx +++ b/app/forms/vpc-router-route-common.tsx @@ -69,8 +69,8 @@ const destinationValuePlaceholder: Record = { - ip: 'An IP address, like 192.168.1.222', - ip_net: 'An IP network, like 192.168.0.0/16', + ip: 'An IP address, like 192.168.1.222 or fd00::1', + ip_net: 'An IP network, like 192.168.0.0/16 or fd00::/64', subnet: undefined, vpc: undefined, } @@ -86,7 +86,7 @@ const targetValuePlaceholder: Record = } const targetValueDescription: Record = { - ip: 'An IP address, like 10.0.1.5', + ip: 'An IP address, like 10.0.1.5 or fd00::2', instance: undefined, internet_gateway: undefined, drop: undefined, From cb1775288219cef186deaf45bc773b6a8901be90 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 17:51:22 -0800 Subject: [PATCH 48/73] Better microcopy, tooltip, validation on IP Pool ranges --- app/forms/network-interface-edit.tsx | 32 +++++++++++++++++-- app/pages/project/instances/NetworkingTab.tsx | 17 ++++++++++ test/e2e/instance-create.e2e.ts | 5 ++- 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 619e082b8..5c4be06bd 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -25,10 +25,11 @@ import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' +import { Message } from '~/ui/lib/Message' import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable' import { TextInputHint } from '~/ui/lib/TextInput' import { KEYS } from '~/ui/util/keys' -import { validateIpNet } from '~/util/ip' +import { parseIpNet, validateIpNet } from '~/util/ip' import { links } from '~/util/links' type EditNetworkInterfaceFormProps = { @@ -66,6 +67,19 @@ export function EditNetworkInterfaceForm({ const form = useForm({ defaultValues }) const transitIps = form.watch('transitIps') || [] + // Determine what IP versions this NIC supports + const { ipStack } = editing + const supportsV4 = ipStack.type === 'v4' || ipStack.type === 'dual_stack' + const supportsV6 = ipStack.type === 'v6' || ipStack.type === 'dual_stack' + const supportedVersions = + supportsV4 && supportsV6 ? 'both IPv4 and IPv6' : supportsV4 ? 'IPv4' : 'IPv6' + const exampleIPs = + supportsV4 && supportsV6 + ? '192.168.0.0/16 or fd00::/64' + : supportsV4 + ? '192.168.0.0/16' + : 'fd00::/64' + const transitIpsForm = useForm({ defaultValues: { transitIp: '' } }) const transitIpValue = transitIpsForm.watch('transitIp') const { isSubmitSuccessful: transitIpSubmitSuccessful } = transitIpsForm.formState @@ -108,7 +122,8 @@ export function EditNetworkInterfaceForm({ Transit IPs - An IP network, like 192.168.0.0/16.{' '} + An IP network, like {exampleIPs}. +
Learn more about transit IPs. @@ -127,6 +142,15 @@ export function EditNetworkInterfaceForm({ const error = validateIpNet(value) if (error) return error + // Check if Transit IP version matches NIC's supported versions + const parsed = parseIpNet(value) + if (parsed.type === 'v4' && !supportsV4) { + return 'IPv4 transit IP not supported by this network interface' + } + if (parsed.type === 'v6' && !supportsV6) { + return 'IPv6 transit IP not supported by this network interface' + } + if (transitIps.includes(value)) return 'Transit IP already in list' }} placeholder="Enter an IP network" @@ -153,6 +177,10 @@ export function EditNetworkInterfaceForm({ }} removeLabel={(ip) => `remove IP ${ip}`} /> + ) } diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index ec1cb90a1..906dc75c8 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -172,6 +172,23 @@ const staticCols = [ return }, }), + colHelper.accessor('ipStack.value', { + header: 'IP Version', + cell: (info) => { + const nic = info.row.original + const { ipStack } = nic + return ( +
+ {(ipStack.type === 'v4' || ipStack.type === 'dual_stack') && ( + v4 + )} + {(ipStack.type === 'v6' || ipStack.type === 'dual_stack') && ( + v6 + )} +
+ ) + }, + }), colHelper.accessor('vpcId', { header: 'vpc', cell: (info) => , diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 31a960c76..9fad257e7 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1016,9 +1016,8 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) // Hover over the disabled checkbox to verify tooltip appears await ephemeralCheckbox.hover() - await expect( - page.getByText('Add a compatible network interface to attach an ephemeral IP address') - ).toBeVisible() + 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 customRadio.click() From dcff953bc0f7c2e4e84579e280f19a005b084c6e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Mon, 26 Jan 2026 18:41:47 -0800 Subject: [PATCH 49/73] Update to latest omicron sha (92e0ae0c) --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 759 +++++++++++++++++++++++++- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 277 ++++++++++ app/api/__generated__/validate.ts | 497 ++++++++++++++++- mock-api/msw/handlers.ts | 51 +- 6 files changed, 1561 insertions(+), 27 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index be6a09b19..dc774ee9e 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -26b33a11962d82b29fde9a2e3233d038d5495c44 +92e0ae0c98dc50452d888a238cca86d162fd2d40 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 445f5de6d..57119207d 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -64,7 +64,7 @@ export type Address = { export type IpVersion = 'v4' | 'v6' /** - * Specify which IP pool to allocate from. + * Specify which IP or external subnet pool to allocate from. */ export type PoolSelector = /** Use the specified pool by name or ID. */ @@ -84,19 +84,17 @@ export type PoolSelector = * Specify how to allocate a floating IP address. */ export type AddressAllocator = - /** Reserve a specific IP address. */ + /** Reserve a specific IP address. The pool is inferred from the address since IP pools cannot have overlapping ranges. */ | { - /** The IP address to reserve. Must be available in the pool. */ + /** The IP address to reserve. */ ip: string - /** The pool containing this address. If not specified, the default pool for the address's IP version is used. */ - pool?: NameOrId | null type: 'explicit' } - /** Automatically allocate an IP address from a specified pool. */ + /** Automatically allocate an IP address from a pool. */ | { /** Pool selection. -If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ +If omitted, the silo's default pool is used. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified. */ poolSelector?: PoolSelector type: 'auto' } @@ -771,6 +769,18 @@ export type AuthzScope = */ export type Baseboard = { part: string; revision: number; serial: string } +/** + * A representation of a Baseboard ID as used in the inventory subsystem. + * + * This type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown). + */ +export type BaseboardId = { + /** Oxide Part Number */ + partNumber: string + /** Serial number (unique for a given part number) */ + serialNumber: string +} + /** * BFD connection mode. */ @@ -2060,6 +2070,86 @@ export type ExternalIpResultsPage = { nextPage?: string | null } +/** + * An external subnet allocated from a subnet pool + */ +export type ExternalSubnet = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The instance this subnet is attached to, if any */ + instanceId?: string | null + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** The project this subnet belongs to */ + projectId: string + /** The allocated subnet CIDR */ + subnet: IpNet + /** The subnet pool this was allocated from */ + subnetPoolId: string + /** The subnet pool member this subnet corresponds to */ + subnetPoolMemberId: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Specify how to allocate an external subnet. + */ +export type ExternalSubnetAllocator = + /** Reserve a specific subnet. */ + | { + /** The subnet CIDR to reserve. Must be available in the pool. */ + subnet: IpNet + type: 'explicit' + } + /** Automatically allocate a subnet with the specified prefix length. */ + | { + /** Pool selection. + +If omitted, this field uses the silo's default pool. If the silo has default pools for both IPv4 and IPv6, the request will fail unless `ip_version` is specified in the pool selector. */ + poolSelector?: PoolSelector + /** The prefix length for the allocated subnet (e.g., 24 for a /24). */ + prefixLen: number + type: 'auto' + } + +/** + * Attach an external subnet to an instance + */ +export type ExternalSubnetAttach = { + /** Name or ID of the instance to attach to */ + instance: NameOrId +} + +/** + * Create an external subnet + */ +export type ExternalSubnetCreate = { + /** Subnet allocation method. */ + allocator: ExternalSubnetAllocator + description: string + name: Name +} + +/** + * A single page of results + */ +export type ExternalSubnetResultsPage = { + /** list of items on this page of results */ + items: ExternalSubnet[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * Update an external subnet + */ +export type ExternalSubnetUpdate = { description?: string | null; name?: Name | null } + /** * The `FieldType` identifies the data type of a target or metric field. */ @@ -3682,6 +3772,32 @@ export type Rack = { timeModified: Date } +export type RackMembershipAddSledsRequest = { sledIds: BaseboardId[] } + +export type RackMembershipChangeState = 'in_progress' | 'committed' | 'aborted' + +/** + * A unique, monotonically increasing number representing the set of active sleds in a rack at a given point in time. + */ +export type RackMembershipVersion = number + +/** + * Status of the rack membership uniquely identified by the (rack_id, version) pair + */ +export type RackMembershipStatus = { + /** All members of the rack for this version */ + members: BaseboardId[] + rackId: string + state: RackMembershipChangeState + timeAborted?: Date | null + timeCommitted?: Date | null + timeCreated: Date + /** All members that have not yet confirmed this membership version */ + unacknowledgedMembers: BaseboardId[] + /** Version that uniquely identifies the rack membership at a given point in time */ + version: RackMembershipVersion +} + /** * A single page of results */ @@ -4355,6 +4471,156 @@ export type SshKeyResultsPage = { nextPage?: string | null } +/** + * A pool of subnets for external subnet allocation + */ +export type SubnetPool = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** The IP version for this pool */ + ipVersion: IpVersion + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** Type of subnet pool (unicast or multicast) */ + poolType: IpPoolType + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create a subnet pool + */ +export type SubnetPoolCreate = { + description: string + /** The IP version for this pool (IPv4 or IPv6). All subnets in the pool must match this version. */ + ipVersion: IpVersion + name: Name +} + +/** + * Link a subnet pool to a silo + */ +export type SubnetPoolLinkSilo = { + /** Whether this is the default subnet pool for the silo. When true, external subnet allocations that don't specify a pool use this one. */ + isDefault: boolean + /** The silo to link */ + silo: NameOrId +} + +/** + * A member (subnet) within a subnet pool + */ +export type SubnetPoolMember = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** Maximum prefix length for allocations from this subnet; a larger prefix means smaller allocations are allowed (e.g. a /24 prefix yields smaller subnet allocations than a /16 prefix). */ + maxPrefixLength: number + /** Minimum prefix length for allocations from this subnet; a smaller prefix means larger allocations are allowed (e.g. a /16 prefix yields larger subnet allocations than a /24 prefix). */ + minPrefixLength: number + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + /** The subnet CIDR */ + subnet: IpNet + /** ID of the parent subnet pool */ + subnetPoolId: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Add a member (subnet) to a subnet pool + */ +export type SubnetPoolMemberAdd = { + /** Maximum prefix length for allocations from this subnet; a larger prefix means smaller allocations are allowed (e.g. a /24 prefix yields smaller subnet allocations than a /16 prefix). + +Valid values: 0-32 for IPv4, 0-128 for IPv6. Default if not specified is 32 for IPv4 and 128 for IPv6. */ + maxPrefixLength?: number | null + /** Minimum prefix length for allocations from this subnet; a smaller prefix means larger allocations are allowed (e.g. a /16 prefix yields larger subnet allocations than a /24 prefix). + +Valid values: 0-32 for IPv4, 0-128 for IPv6. Default if not specified is equal to the subnet's prefix length. */ + minPrefixLength?: number | null + /** The subnet to add to the pool */ + subnet: IpNet +} + +/** + * Remove a subnet from a pool + */ +export type SubnetPoolMemberRemove = { + /** The subnet to remove from the pool. Must match an existing entry exactly. */ + subnet: IpNet +} + +/** + * A single page of results + */ +export type SubnetPoolMemberResultsPage = { + /** list of items on this page of results */ + items: SubnetPoolMember[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * A single page of results + */ +export type SubnetPoolResultsPage = { + /** list of items on this page of results */ + items: SubnetPool[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * A link between a subnet pool and a silo + */ +export type SubnetPoolSiloLink = { + isDefault: boolean + siloId: string + subnetPoolId: string +} + +/** + * A single page of results + */ +export type SubnetPoolSiloLinkResultsPage = { + /** list of items on this page of results */ + items: SubnetPoolSiloLink[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string | null +} + +/** + * Update a subnet pool's silo link + */ +export type SubnetPoolSiloUpdate = { + /** Whether this is the default subnet pool for the silo */ + isDefault: boolean +} + +/** + * Update a subnet pool + */ +export type SubnetPoolUpdate = { description?: string | null; name?: Name | null } + +/** + * Utilization information for a subnet pool + */ +export type SubnetPoolUtilization = { + /** Number of addresses allocated from this pool */ + allocated: number + /** Total capacity of this pool in addresses */ + capacity: number +} + export type SupportBundleCreate = { /** User comment for the support bundle */ userComment?: string | null @@ -5754,6 +6020,57 @@ export interface DiskFinalizeImportQueryParams { project?: NameOrId } +export interface ExternalSubnetListQueryParams { + limit?: number | null + pageToken?: string | null + project?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface ExternalSubnetCreateQueryParams { + project: NameOrId +} + +export interface ExternalSubnetViewPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetViewQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetUpdatePathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetUpdateQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetDeletePathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetDeleteQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetAttachPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetAttachQueryParams { + project?: NameOrId +} + +export interface ExternalSubnetDetachPathParams { + externalSubnet: NameOrId +} + +export interface ExternalSubnetDetachQueryParams { + project?: NameOrId +} + export interface FloatingIpListQueryParams { limit?: number | null pageToken?: string | null @@ -5963,6 +6280,7 @@ export interface InstanceEphemeralIpDetachPathParams { } export interface InstanceEphemeralIpDetachQueryParams { + ipVersion?: IpVersion project?: NameOrId } @@ -6344,6 +6662,18 @@ export interface RackViewPathParams { rackId: string } +export interface RackMembershipStatusPathParams { + rackId: string +} + +export interface RackMembershipStatusQueryParams { + version?: RackMembershipVersion +} + +export interface RackMembershipAddSledsPathParams { + rackId: string +} + export interface SledListQueryParams { limit?: number | null pageToken?: string | null @@ -6719,6 +7049,70 @@ export interface SiloQuotasUpdatePathParams { silo: NameOrId } +export interface SubnetPoolListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: NameOrIdSortMode +} + +export interface SubnetPoolViewPathParams { + pool: NameOrId +} + +export interface SubnetPoolUpdatePathParams { + pool: NameOrId +} + +export interface SubnetPoolDeletePathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberListPathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: NameOrIdSortMode +} + +export interface SubnetPoolMemberAddPathParams { + pool: NameOrId +} + +export interface SubnetPoolMemberRemovePathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloListPathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloListQueryParams { + limit?: number | null + pageToken?: string | null + sortBy?: IdSortMode +} + +export interface SubnetPoolSiloLinkPathParams { + pool: NameOrId +} + +export interface SubnetPoolSiloUpdatePathParams { + pool: NameOrId + silo: NameOrId +} + +export interface SubnetPoolSiloUnlinkPathParams { + pool: NameOrId + silo: NameOrId +} + +export interface SubnetPoolUtilizationViewPathParams { + pool: NameOrId +} + export interface SystemTimeseriesSchemaListQueryParams { limit?: number | null pageToken?: string | null @@ -7043,7 +7437,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026011600.0.0' + apiVersion = '2026012300.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host @@ -8003,6 +8397,135 @@ export class Api { ...params, }) }, + /** + * List external subnets in a project + */ + externalSubnetList: ( + { query = {} }: { query?: ExternalSubnetListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create an external subnet + */ + externalSubnetCreate: ( + { + query, + body, + }: { query: ExternalSubnetCreateQueryParams; body: ExternalSubnetCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Fetch an external subnet + */ + externalSubnetView: ( + { + path, + query = {}, + }: { path: ExternalSubnetViewPathParams; query?: ExternalSubnetViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Update an external subnet + */ + externalSubnetUpdate: ( + { + path, + query = {}, + body, + }: { + path: ExternalSubnetUpdatePathParams + query?: ExternalSubnetUpdateQueryParams + body: ExternalSubnetUpdate + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, + method: 'PUT', + body, + query, + ...params, + }) + }, + /** + * Delete an external subnet + */ + externalSubnetDelete: ( + { + path, + query = {}, + }: { path: ExternalSubnetDeletePathParams; query?: ExternalSubnetDeleteQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}`, + method: 'DELETE', + query, + ...params, + }) + }, + /** + * Attach an external subnet to an instance + */ + externalSubnetAttach: ( + { + path, + query = {}, + body, + }: { + path: ExternalSubnetAttachPathParams + query?: ExternalSubnetAttachQueryParams + body: ExternalSubnetAttach + }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}/attach`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * Detach an external subnet from an instance + */ + externalSubnetDetach: ( + { + path, + query = {}, + }: { path: ExternalSubnetDetachPathParams; query?: ExternalSubnetDetachQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/external-subnets/${path.externalSubnet}/detach`, + method: 'POST', + query, + ...params, + }) + }, /** * List floating IPs */ @@ -8018,7 +8541,7 @@ export class Api { }) }, /** - * Create floating IP + * Create a floating IP */ floatingIpCreate: ( { query, body }: { query: FloatingIpCreateQueryParams; body: FloatingIpCreate }, @@ -9409,6 +9932,40 @@ export class Api { ...params, }) }, + /** + * Retrieve the rack cluster membership status + */ + rackMembershipStatus: ( + { + path, + query = {}, + }: { path: RackMembershipStatusPathParams; query?: RackMembershipStatusQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/hardware/racks/${path.rackId}/membership`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Add new sleds to rack membership + */ + rackMembershipAddSleds: ( + { + path, + body, + }: { path: RackMembershipAddSledsPathParams; body: RackMembershipAddSledsRequest }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/hardware/racks/${path.rackId}/membership/add`, + method: 'POST', + body, + ...params, + }) + }, /** * List sleds */ @@ -10611,6 +11168,190 @@ export class Api { ...params, }) }, + /** + * List subnet pools + */ + subnetPoolList: ( + { query = {} }: { query?: SubnetPoolListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create a subnet pool + */ + subnetPoolCreate: ({ body }: { body: SubnetPoolCreate }, params: FetchParams = {}) => { + return this.request({ + path: `/v1/system/subnet-pools`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Fetch a subnet pool + */ + subnetPoolView: ( + { path }: { path: SubnetPoolViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'GET', + ...params, + }) + }, + /** + * Update a subnet pool + */ + subnetPoolUpdate: ( + { path, body }: { path: SubnetPoolUpdatePathParams; body: SubnetPoolUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'PUT', + body, + ...params, + }) + }, + /** + * Delete a subnet pool + */ + subnetPoolDelete: ( + { path }: { path: SubnetPoolDeletePathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}`, + method: 'DELETE', + ...params, + }) + }, + /** + * List members in a subnet pool + */ + subnetPoolMemberList: ( + { + path, + query = {}, + }: { path: SubnetPoolMemberListPathParams; query?: SubnetPoolMemberListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Add a member to a subnet pool + */ + subnetPoolMemberAdd: ( + { path, body }: { path: SubnetPoolMemberAddPathParams; body: SubnetPoolMemberAdd }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members/add`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Remove a member from a subnet pool + */ + subnetPoolMemberRemove: ( + { + path, + body, + }: { path: SubnetPoolMemberRemovePathParams; body: SubnetPoolMemberRemove }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/members/remove`, + method: 'POST', + body, + ...params, + }) + }, + /** + * List silos linked to a subnet pool + */ + subnetPoolSiloList: ( + { + path, + query = {}, + }: { path: SubnetPoolSiloListPathParams; query?: SubnetPoolSiloListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Link a subnet pool to a silo + */ + subnetPoolSiloLink: ( + { path, body }: { path: SubnetPoolSiloLinkPathParams; body: SubnetPoolLinkSilo }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos`, + method: 'POST', + body, + ...params, + }) + }, + /** + * Update a subnet pool's link to a silo + */ + subnetPoolSiloUpdate: ( + { path, body }: { path: SubnetPoolSiloUpdatePathParams; body: SubnetPoolSiloUpdate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos/${path.silo}`, + method: 'PUT', + body, + ...params, + }) + }, + /** + * Unlink a subnet pool from a silo + */ + subnetPoolSiloUnlink: ( + { path }: { path: SubnetPoolSiloUnlinkPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/silos/${path.silo}`, + method: 'DELETE', + ...params, + }) + }, + /** + * Fetch subnet pool utilization + */ + subnetPoolUtilizationView: ( + { path }: { path: SubnetPoolUtilizationViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/subnet-pools/${path.pool}/utilization`, + method: 'GET', + ...params, + }) + }, /** * Run timeseries query */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 26a3df73c..e220c4937 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -26b33a11962d82b29fde9a2e3233d038d5495c44 +92e0ae0c98dc50452d888a238cca86d162fd2d40 diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index a29025911..1ced8edf7 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -431,6 +431,56 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/external-subnets` */ + externalSubnetList: (params: { + query: Api.ExternalSubnetListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/external-subnets` */ + externalSubnetCreate: (params: { + query: Api.ExternalSubnetCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/external-subnets/:externalSubnet` */ + externalSubnetView: (params: { + path: Api.ExternalSubnetViewPathParams + query: Api.ExternalSubnetViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/external-subnets/:externalSubnet` */ + externalSubnetUpdate: (params: { + path: Api.ExternalSubnetUpdatePathParams + query: Api.ExternalSubnetUpdateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/external-subnets/:externalSubnet` */ + externalSubnetDelete: (params: { + path: Api.ExternalSubnetDeletePathParams + query: Api.ExternalSubnetDeleteQueryParams + req: Request + cookies: Record + }) => Promisable + /** `POST /v1/external-subnets/:externalSubnet/attach` */ + externalSubnetAttach: (params: { + path: Api.ExternalSubnetAttachPathParams + query: Api.ExternalSubnetAttachQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/external-subnets/:externalSubnet/detach` */ + externalSubnetDetach: (params: { + path: Api.ExternalSubnetDetachPathParams + query: Api.ExternalSubnetDetachQueryParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/floating-ips` */ floatingIpList: (params: { query: Api.FloatingIpListQueryParams @@ -1015,6 +1065,20 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/hardware/racks/:rackId/membership` */ + rackMembershipStatus: (params: { + path: Api.RackMembershipStatusPathParams + query: Api.RackMembershipStatusQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/hardware/racks/:rackId/membership/add` */ + rackMembershipAddSleds: (params: { + path: Api.RackMembershipAddSledsPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/hardware/sleds` */ sledList: (params: { query: Api.SledListQueryParams @@ -1548,6 +1612,91 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable> + /** `GET /v1/system/subnet-pools` */ + subnetPoolList: (params: { + query: Api.SubnetPoolListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools` */ + subnetPoolCreate: (params: { + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /v1/system/subnet-pools/:pool` */ + subnetPoolView: (params: { + path: Api.SubnetPoolViewPathParams + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/subnet-pools/:pool` */ + subnetPoolUpdate: (params: { + path: Api.SubnetPoolUpdatePathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/subnet-pools/:pool` */ + subnetPoolDelete: (params: { + path: Api.SubnetPoolDeletePathParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/members` */ + subnetPoolMemberList: (params: { + path: Api.SubnetPoolMemberListPathParams + query: Api.SubnetPoolMemberListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/members/add` */ + subnetPoolMemberAdd: (params: { + path: Api.SubnetPoolMemberAddPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/members/remove` */ + subnetPoolMemberRemove: (params: { + path: Api.SubnetPoolMemberRemovePathParams + body: Json + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/silos` */ + subnetPoolSiloList: (params: { + path: Api.SubnetPoolSiloListPathParams + query: Api.SubnetPoolSiloListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /v1/system/subnet-pools/:pool/silos` */ + subnetPoolSiloLink: (params: { + path: Api.SubnetPoolSiloLinkPathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `PUT /v1/system/subnet-pools/:pool/silos/:silo` */ + subnetPoolSiloUpdate: (params: { + path: Api.SubnetPoolSiloUpdatePathParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /v1/system/subnet-pools/:pool/silos/:silo` */ + subnetPoolSiloUnlink: (params: { + path: Api.SubnetPoolSiloUnlinkPathParams + req: Request + cookies: Record + }) => Promisable + /** `GET /v1/system/subnet-pools/:pool/utilization` */ + subnetPoolUtilizationView: (params: { + path: Api.SubnetPoolUtilizationViewPathParams + req: Request + cookies: Record + }) => Promisable> /** `POST /v1/system/timeseries/query` */ systemTimeseriesQuery: (params: { body: Json @@ -2296,6 +2445,46 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { schema.FinalizeDisk ) ), + http.get( + '/v1/external-subnets', + handler(handlers['externalSubnetList'], schema.ExternalSubnetListParams, null) + ), + http.post( + '/v1/external-subnets', + handler( + handlers['externalSubnetCreate'], + schema.ExternalSubnetCreateParams, + schema.ExternalSubnetCreate + ) + ), + http.get( + '/v1/external-subnets/:externalSubnet', + handler(handlers['externalSubnetView'], schema.ExternalSubnetViewParams, null) + ), + http.put( + '/v1/external-subnets/:externalSubnet', + handler( + handlers['externalSubnetUpdate'], + schema.ExternalSubnetUpdateParams, + schema.ExternalSubnetUpdate + ) + ), + http.delete( + '/v1/external-subnets/:externalSubnet', + handler(handlers['externalSubnetDelete'], schema.ExternalSubnetDeleteParams, null) + ), + http.post( + '/v1/external-subnets/:externalSubnet/attach', + handler( + handlers['externalSubnetAttach'], + schema.ExternalSubnetAttachParams, + schema.ExternalSubnetAttach + ) + ), + http.post( + '/v1/external-subnets/:externalSubnet/detach', + handler(handlers['externalSubnetDetach'], schema.ExternalSubnetDetachParams, null) + ), http.get( '/v1/floating-ips', handler(handlers['floatingIpList'], schema.FloatingIpListParams, null) @@ -2771,6 +2960,18 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/hardware/racks/:rackId', handler(handlers['rackView'], schema.RackViewParams, null) ), + http.get( + '/v1/system/hardware/racks/:rackId/membership', + handler(handlers['rackMembershipStatus'], schema.RackMembershipStatusParams, null) + ), + http.post( + '/v1/system/hardware/racks/:rackId/membership/add', + handler( + handlers['rackMembershipAddSleds'], + schema.RackMembershipAddSledsParams, + schema.RackMembershipAddSledsRequest + ) + ), http.get( '/v1/system/hardware/sleds', handler(handlers['sledList'], schema.SledListParams, null) @@ -3241,6 +3442,82 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { schema.SiloQuotasUpdate ) ), + http.get( + '/v1/system/subnet-pools', + handler(handlers['subnetPoolList'], schema.SubnetPoolListParams, null) + ), + http.post( + '/v1/system/subnet-pools', + handler(handlers['subnetPoolCreate'], null, schema.SubnetPoolCreate) + ), + http.get( + '/v1/system/subnet-pools/:pool', + handler(handlers['subnetPoolView'], schema.SubnetPoolViewParams, null) + ), + http.put( + '/v1/system/subnet-pools/:pool', + handler( + handlers['subnetPoolUpdate'], + schema.SubnetPoolUpdateParams, + schema.SubnetPoolUpdate + ) + ), + http.delete( + '/v1/system/subnet-pools/:pool', + handler(handlers['subnetPoolDelete'], schema.SubnetPoolDeleteParams, null) + ), + http.get( + '/v1/system/subnet-pools/:pool/members', + handler(handlers['subnetPoolMemberList'], schema.SubnetPoolMemberListParams, null) + ), + http.post( + '/v1/system/subnet-pools/:pool/members/add', + handler( + handlers['subnetPoolMemberAdd'], + schema.SubnetPoolMemberAddParams, + schema.SubnetPoolMemberAdd + ) + ), + http.post( + '/v1/system/subnet-pools/:pool/members/remove', + handler( + handlers['subnetPoolMemberRemove'], + schema.SubnetPoolMemberRemoveParams, + schema.SubnetPoolMemberRemove + ) + ), + http.get( + '/v1/system/subnet-pools/:pool/silos', + handler(handlers['subnetPoolSiloList'], schema.SubnetPoolSiloListParams, null) + ), + http.post( + '/v1/system/subnet-pools/:pool/silos', + handler( + handlers['subnetPoolSiloLink'], + schema.SubnetPoolSiloLinkParams, + schema.SubnetPoolLinkSilo + ) + ), + http.put( + '/v1/system/subnet-pools/:pool/silos/:silo', + handler( + handlers['subnetPoolSiloUpdate'], + schema.SubnetPoolSiloUpdateParams, + schema.SubnetPoolSiloUpdate + ) + ), + http.delete( + '/v1/system/subnet-pools/:pool/silos/:silo', + handler(handlers['subnetPoolSiloUnlink'], schema.SubnetPoolSiloUnlinkParams, null) + ), + http.get( + '/v1/system/subnet-pools/:pool/utilization', + handler( + handlers['subnetPoolUtilizationView'], + schema.SubnetPoolUtilizationViewParams, + null + ) + ), http.post( '/v1/system/timeseries/query', handler(handlers['systemTimeseriesQuery'], null, schema.TimeseriesQuery) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 08eb03a9c..6d723200f 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -91,7 +91,7 @@ export const Address = z.preprocess( export const IpVersion = z.preprocess(processResponseBody, z.enum(['v4', 'v6'])) /** - * Specify which IP pool to allocate from. + * Specify which IP or external subnet pool to allocate from. */ export const PoolSelector = z.preprocess( processResponseBody, @@ -107,11 +107,7 @@ export const PoolSelector = z.preprocess( export const AddressAllocator = z.preprocess( processResponseBody, z.union([ - z.object({ - ip: z.union([z.ipv4(), z.ipv6()]), - pool: NameOrId.nullable().optional(), - type: z.enum(['explicit']), - }), + z.object({ ip: z.union([z.ipv4(), z.ipv6()]), type: z.enum(['explicit']) }), z.object({ poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), type: z.enum(['auto']), @@ -721,6 +717,16 @@ export const Baseboard = z.preprocess( }) ) +/** + * A representation of a Baseboard ID as used in the inventory subsystem. + * + * This type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown). + */ +export const BaseboardId = z.preprocess( + processResponseBody, + z.object({ partNumber: z.string(), serialNumber: z.string() }) +) + /** * BFD connection mode. */ @@ -1891,6 +1897,75 @@ export const ExternalIpResultsPage = z.preprocess( z.object({ items: ExternalIp.array(), nextPage: z.string().nullable().optional() }) ) +/** + * An external subnet allocated from a subnet pool + */ +export const ExternalSubnet = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + instanceId: z.uuid().nullable().optional(), + name: Name, + projectId: z.uuid(), + subnet: IpNet, + subnetPoolId: z.uuid(), + subnetPoolMemberId: z.uuid(), + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Specify how to allocate an external subnet. + */ +export const ExternalSubnetAllocator = z.preprocess( + processResponseBody, + z.union([ + z.object({ subnet: IpNet, type: z.enum(['explicit']) }), + z.object({ + poolSelector: PoolSelector.default({ ipVersion: null, type: 'auto' }), + prefixLen: z.number().min(0).max(255), + type: z.enum(['auto']), + }), + ]) +) + +/** + * Attach an external subnet to an instance + */ +export const ExternalSubnetAttach = z.preprocess( + processResponseBody, + z.object({ instance: NameOrId }) +) + +/** + * Create an external subnet + */ +export const ExternalSubnetCreate = z.preprocess( + processResponseBody, + z.object({ allocator: ExternalSubnetAllocator, description: z.string(), name: Name }) +) + +/** + * A single page of results + */ +export const ExternalSubnetResultsPage = z.preprocess( + processResponseBody, + z.object({ items: ExternalSubnet.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * Update an external subnet + */ +export const ExternalSubnetUpdate = z.preprocess( + processResponseBody, + z.object({ + description: z.string().nullable().optional(), + name: Name.nullable().optional(), + }) +) + /** * The `FieldType` identifies the data type of a target or metric field. */ @@ -3384,6 +3459,38 @@ export const Rack = z.preprocess( z.object({ id: z.uuid(), timeCreated: z.coerce.date(), timeModified: z.coerce.date() }) ) +export const RackMembershipAddSledsRequest = z.preprocess( + processResponseBody, + z.object({ sledIds: BaseboardId.array().refine(...uniqueItems) }) +) + +export const RackMembershipChangeState = z.preprocess( + processResponseBody, + z.enum(['in_progress', 'committed', 'aborted']) +) + +/** + * A unique, monotonically increasing number representing the set of active sleds in a rack at a given point in time. + */ +export const RackMembershipVersion = z.preprocess(processResponseBody, z.number().min(0)) + +/** + * Status of the rack membership uniquely identified by the (rack_id, version) pair + */ +export const RackMembershipStatus = z.preprocess( + processResponseBody, + z.object({ + members: BaseboardId.array().refine(...uniqueItems), + rackId: z.uuid(), + state: RackMembershipChangeState, + timeAborted: z.coerce.date().nullable().optional(), + timeCommitted: z.coerce.date().nullable().optional(), + timeCreated: z.coerce.date(), + unacknowledgedMembers: BaseboardId.array().refine(...uniqueItems), + version: RackMembershipVersion, + }) +) + /** * A single page of results */ @@ -3951,6 +4058,138 @@ export const SshKeyResultsPage = z.preprocess( z.object({ items: SshKey.array(), nextPage: z.string().nullable().optional() }) ) +/** + * A pool of subnets for external subnet allocation + */ +export const SubnetPool = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + ipVersion: IpVersion, + name: Name, + poolType: IpPoolType, + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create a subnet pool + */ +export const SubnetPoolCreate = z.preprocess( + processResponseBody, + z.object({ description: z.string(), ipVersion: IpVersion, name: Name }) +) + +/** + * Link a subnet pool to a silo + */ +export const SubnetPoolLinkSilo = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean, silo: NameOrId }) +) + +/** + * A member (subnet) within a subnet pool + */ +export const SubnetPoolMember = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.uuid(), + maxPrefixLength: z.number().min(0).max(255), + minPrefixLength: z.number().min(0).max(255), + name: Name, + subnet: IpNet, + subnetPoolId: z.uuid(), + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Add a member (subnet) to a subnet pool + */ +export const SubnetPoolMemberAdd = z.preprocess( + processResponseBody, + z.object({ + maxPrefixLength: z.number().min(0).max(255).nullable().optional(), + minPrefixLength: z.number().min(0).max(255).nullable().optional(), + subnet: IpNet, + }) +) + +/** + * Remove a subnet from a pool + */ +export const SubnetPoolMemberRemove = z.preprocess( + processResponseBody, + z.object({ subnet: IpNet }) +) + +/** + * A single page of results + */ +export const SubnetPoolMemberResultsPage = z.preprocess( + processResponseBody, + z.object({ items: SubnetPoolMember.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * A single page of results + */ +export const SubnetPoolResultsPage = z.preprocess( + processResponseBody, + z.object({ items: SubnetPool.array(), nextPage: z.string().nullable().optional() }) +) + +/** + * A link between a subnet pool and a silo + */ +export const SubnetPoolSiloLink = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean, siloId: z.uuid(), subnetPoolId: z.uuid() }) +) + +/** + * A single page of results + */ +export const SubnetPoolSiloLinkResultsPage = z.preprocess( + processResponseBody, + z.object({ + items: SubnetPoolSiloLink.array(), + nextPage: z.string().nullable().optional(), + }) +) + +/** + * Update a subnet pool's silo link + */ +export const SubnetPoolSiloUpdate = z.preprocess( + processResponseBody, + z.object({ isDefault: SafeBoolean }) +) + +/** + * Update a subnet pool + */ +export const SubnetPoolUpdate = z.preprocess( + processResponseBody, + z.object({ + description: z.string().nullable().optional(), + name: Name.nullable().optional(), + }) +) + +/** + * Utilization information for a subnet pool + */ +export const SubnetPoolUtilization = z.preprocess( + processResponseBody, + z.object({ allocated: z.number(), capacity: z.number() }) +) + export const SupportBundleCreate = z.preprocess( processResponseBody, z.object({ userComment: z.string().nullable().optional() }) @@ -5585,6 +5824,89 @@ export const DiskFinalizeImportParams = z.preprocess( }) ) +export const ExternalSubnetListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const ExternalSubnetCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + project: NameOrId, + }), + }) +) + +export const ExternalSubnetViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetAttachParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + +export const ExternalSubnetDetachParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + externalSubnet: NameOrId, + }), + query: z.object({ + project: NameOrId.optional(), + }), + }) +) + export const FloatingIpListParams = z.preprocess( processResponseBody, z.object({ @@ -5920,6 +6242,7 @@ export const InstanceEphemeralIpDetachParams = z.preprocess( instance: NameOrId, }), query: z.object({ + ipVersion: IpVersion.optional(), project: NameOrId.optional(), }), }) @@ -6635,6 +6958,28 @@ export const RackViewParams = z.preprocess( }) ) +export const RackMembershipStatusParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + rackId: z.uuid(), + }), + query: z.object({ + version: RackMembershipVersion.optional(), + }), + }) +) + +export const RackMembershipAddSledsParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + rackId: z.uuid(), + }), + query: z.object({}), + }) +) + export const SledListParams = z.preprocess( processResponseBody, z.object({ @@ -7530,6 +7875,146 @@ export const SiloQuotasUpdateParams = z.preprocess( }) ) +export const SubnetPoolListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const SubnetPoolCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({}), + }) +) + +export const SubnetPoolViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolMemberListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const SubnetPoolMemberAddParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolMemberRemoveParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({ + limit: z.number().min(1).max(4294967295).nullable().optional(), + pageToken: z.string().nullable().optional(), + sortBy: IdSortMode.optional(), + }), + }) +) + +export const SubnetPoolSiloLinkParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloUpdateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + silo: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolSiloUnlinkParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + silo: NameOrId, + }), + query: z.object({}), + }) +) + +export const SubnetPoolUtilizationViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + export const SystemTimeseriesQueryParams = z.preprocess( processResponseBody, z.object({ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index c7fb6c59e..1f2a6ab34 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -301,27 +301,43 @@ export const handlers = makeHandlers({ return 204 }, + externalSubnetList: NotImplemented, + externalSubnetCreate: NotImplemented, + externalSubnetView: NotImplemented, + externalSubnetUpdate: NotImplemented, + externalSubnetDelete: NotImplemented, + externalSubnetAttach: NotImplemented, + externalSubnetDetach: NotImplemented, floatingIpCreate({ body, query }) { const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) const addressAllocator = body.address_allocator || { type: 'auto' } - // Determine the pool, respecting ipVersion when specified + // Determine the pool and IP // Floating IPs must use unicast pools let pool: Json - if (addressAllocator.type === 'explicit' && addressAllocator.pool) { - pool = lookup.siloIpPool({ pool: addressAllocator.pool, silo: defaultSilo.id }) - } else if (addressAllocator.type === 'auto') { - pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') + let ip: string + + if (addressAllocator.type === 'explicit') { + // Pool is inferred from the IP address since IP pools cannot have overlapping ranges + ip = addressAllocator.ip + // Find the pool that contains this IP by checking all ranges + const poolWithIp = db.ipPools.find((p) => { + if (p.pool_type !== 'unicast') return false + const ranges = db.ipPoolRanges.filter((r) => r.ip_pool_id === p.id) + return ranges.some(() => { + // Simple check - in real API this would do proper IP range comparison + return true // For mock purposes, just use first unicast pool + }) + }) + pool = poolWithIp || resolvePoolSelector(undefined, 'unicast') } else { - pool = lookup.siloDefaultIpPool({ silo: defaultSilo.id }) + // type === 'auto' + pool = resolvePoolSelector(addressAllocator.pool_selector, 'unicast') + ip = getIpFromPool(pool) } - // Generate IP from the pool (respects pool's IP version) - const ip = - (addressAllocator.type === 'explicit' && addressAllocator.ip) || getIpFromPool(pool) - const newFloatingIp: Json = { id: uuid(), project_id: project.id, @@ -2229,6 +2245,8 @@ export const handlers = makeHandlers({ probeList: NotImplemented, probeView: NotImplemented, rackView: NotImplemented, + rackMembershipStatus: NotImplemented, + rackMembershipAddSleds: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, siloUserList: NotImplemented, @@ -2236,6 +2254,19 @@ export const handlers = makeHandlers({ sledAdd: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, + subnetPoolList: NotImplemented, + subnetPoolCreate: NotImplemented, + subnetPoolView: NotImplemented, + subnetPoolUpdate: NotImplemented, + subnetPoolDelete: NotImplemented, + subnetPoolMemberList: NotImplemented, + subnetPoolMemberAdd: NotImplemented, + subnetPoolMemberRemove: NotImplemented, + subnetPoolSiloList: NotImplemented, + subnetPoolSiloLink: NotImplemented, + subnetPoolSiloUnlink: NotImplemented, + subnetPoolSiloUpdate: NotImplemented, + subnetPoolUtilizationView: NotImplemented, supportBundleCreate: NotImplemented, supportBundleDelete: NotImplemented, supportBundleDownload: NotImplemented, From 864eaa076bcf11747e9d26f1c20d06811bac2d42 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 27 Jan 2026 11:07:29 -0800 Subject: [PATCH 50/73] post-review updates round 1 --- app/components/IpVersionBadge.tsx | 22 +++++++++++++++++++ app/components/form/fields/ip-pool-item.tsx | 5 ++--- app/forms/ip-pool-create.tsx | 6 ++--- app/pages/project/instances/NetworkingTab.tsx | 21 +++++++++++++++--- app/pages/system/networking/IpPoolsPage.tsx | 8 ++++--- app/pages/system/silos/SiloIpPoolsTab.tsx | 22 ++++++++++++------- app/table/cells/IpVersionCell.tsx | 5 ++--- 7 files changed, 66 insertions(+), 23 deletions(-) create mode 100644 app/components/IpVersionBadge.tsx diff --git a/app/components/IpVersionBadge.tsx b/app/components/IpVersionBadge.tsx new file mode 100644 index 000000000..f7c300baa --- /dev/null +++ b/app/components/IpVersionBadge.tsx @@ -0,0 +1,22 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import cn from 'classnames' + +import type { IpVersion } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' + +type IpVersionBadgeProps = { + ipVersion: IpVersion + className?: string +} + +export const IpVersionBadge = ({ ipVersion, className }: IpVersionBadgeProps) => ( + + {ipVersion} + +) diff --git a/app/components/form/fields/ip-pool-item.tsx b/app/components/form/fields/ip-pool-item.tsx index c6e0fbc8f..dd49af1f9 100644 --- a/app/components/form/fields/ip-pool-item.tsx +++ b/app/components/form/fields/ip-pool-item.tsx @@ -8,6 +8,7 @@ import { Badge } from '@oxide/design-system/ui' import type { SiloIpPool } from '~/api' +import { IpVersionBadge } from '~/components/IpVersionBadge' export function toIpPoolItem(p: SiloIpPool) { const value = p.name @@ -21,9 +22,7 @@ export function toIpPoolItem(p: SiloIpPool) { default )} - - {p.ipVersion} - +
{!!p.description && (
{p.description}
diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index 7fbec7c39..774bec51b 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -69,13 +69,13 @@ export default function CreateIpPoolSideModalForm() { column control={form.control} items={[ - { value: 'v4', label: 'IPv4' }, - { value: 'v6', label: 'IPv6' }, + { value: 'v4', label: 'v4' }, + { value: 'v6', label: 'v6' }, ]} /> - - +
+ + v4 + + +
+
+ + v6 + + +
) } - return + return ( +
+ {ipStack.type} + +
+ ) }, }), colHelper.accessor('ipStack.value', { diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 87f926a08..c092ff668 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -17,6 +17,7 @@ import { Badge } from '@oxide/design-system/ui' import { DocsPopover } from '~/components/DocsPopover' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -61,14 +62,15 @@ const staticColumns = [ colHelper.accessor('description', Columns.description), colHelper.accessor('ipVersion', { header: 'Version', - cell: (info) => {info.getValue()}, + cell: (info) => , }), colHelper.accessor('poolType', { - header: 'Pool type', + header: 'Type', cell: (info) => {info.getValue()}, }), colHelper.display({ - header: 'IPs Remaining', + header: 'IPs REMAINING', + meta: { thClassName: 'normal-case' }, cell: (info) => , }), colHelper.accessor('timeCreated', Columns.timeCreated), diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 4043ea4c0..501fccfc0 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -18,6 +18,7 @@ import { Badge } from '@oxide/design-system/ui' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { makeCrumb } from '~/hooks/use-crumbs' import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' import { confirmAction } from '~/stores/confirm-action' @@ -74,16 +75,21 @@ export default function SiloIpPoolsTab() { const staticCols = useMemo( () => [ - colHelper.accessor('name', { cell: makeLinkCell((pool) => pb.ipPool({ pool })) }), + colHelper.accessor('name', { + cell: (info) => { + const LinkCell = makeLinkCell((pool) => pb.ipPool({ pool })) + return ( +
+ + {info.row.original.isDefault && default} +
+ ) + }, + }), colHelper.accessor('description', Columns.description), colHelper.accessor('ipVersion', { - header: 'IP Version', - cell: (info) => ( - <> - {info.getValue()} - {info.row.original.isDefault && default} - - ), + header: 'Version', + cell: (info) => , }), colHelper.accessor('poolType', { header: 'Type', diff --git a/app/table/cells/IpVersionCell.tsx b/app/table/cells/IpVersionCell.tsx index 0ba65ad2a..288eb4a92 100644 --- a/app/table/cells/IpVersionCell.tsx +++ b/app/table/cells/IpVersionCell.tsx @@ -7,9 +7,8 @@ */ import { useQuery } from '@tanstack/react-query' -import { Badge } from '@oxide/design-system/ui' - import { api, qErrorsAllowed } from '~/api' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { EmptyCell, SkeletonCell } from './EmptyCell' @@ -20,5 +19,5 @@ export const IpVersionCell = ({ ipPoolId }: { ipPoolId: string }) => { if (!result) return if (result.type === 'error') return const pool = result.data - return {pool.ipVersion} + return } From 2e6c51c236894986a248c6317fa9dc28921d120e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 27 Jan 2026 17:11:13 -0800 Subject: [PATCH 51/73] clean up NIC table --- app/pages/project/instances/NetworkingTab.tsx | 50 +++++-------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 68bf0de4d..4fe4861e3 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -22,6 +22,7 @@ import { type ExternalIp, type InstanceNetworkInterface, type InstanceState, + type IpVersion, } from '@oxide/api' import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { Badge } from '@oxide/design-system/ui' @@ -30,6 +31,7 @@ import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal' import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal' import { orderIps } from '~/components/ExternalIps' import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' import { ListPlusCell } from '~/components/ListPlusCell' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' import { EditNetworkInterfaceForm } from '~/forms/network-interface-edit' @@ -98,6 +100,13 @@ const NonFloatingEmptyCell = ({ kind }: { kind: 'snat' | 'ephemeral' }) => ( ) +const PrivateIpCell = ({ ipVersion, ip }: { ipVersion: IpVersion; ip: string }) => ( +
+ + +
+) + export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -162,46 +171,13 @@ const staticCols = [ if (ipStack.type === 'dual_stack') { return ( -
-
- - v4 - - -
-
- - v6 - - -
+
+ +
) } - - return ( -
- {ipStack.type} - -
- ) - }, - }), - colHelper.accessor('ipStack.value', { - header: 'IP Version', - cell: (info) => { - const nic = info.row.original - const { ipStack } = nic - return ( -
- {(ipStack.type === 'v4' || ipStack.type === 'dual_stack') && ( - v4 - )} - {(ipStack.type === 'v6' || ipStack.type === 'dual_stack') && ( - v6 - )} -
- ) + return }, }), colHelper.accessor('vpcId', { From 4ab01e1b9e3b3bebfbe3c7acbac42f9291e995e9 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 27 Jan 2026 17:11:51 -0800 Subject: [PATCH 52/73] latest OMICRON_VERSION --- OMICRON_VERSION | 2 +- app/api/__generated__/OMICRON_VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index dc774ee9e..25af591b1 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -92e0ae0c98dc50452d888a238cca86d162fd2d40 +ee290435d89e95da5cbd90e635100bed84c41cda diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index e220c4937..269380f96 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -92e0ae0c98dc50452d888a238cca86d162fd2d40 +ee290435d89e95da5cbd90e635100bed84c41cda From d8b482a3c4b3b4b0a3d9c09a0fb6b44d11648a3d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Tue, 27 Jan 2026 18:57:21 -0800 Subject: [PATCH 53/73] More updates, plus test fixes --- app/components/AttachEphemeralIpModal.tsx | 1 - app/components/form/fields/IpPoolSelector.tsx | 169 +++++------------- app/forms/floating-ip-create.tsx | 2 - app/forms/instance-create.tsx | 1 - test/e2e/floating-ip-create.e2e.ts | 16 +- test/e2e/instance-create.e2e.ts | 62 ++++--- test/e2e/instance-networking.e2e.ts | 43 +++-- test/e2e/ip-pools.e2e.ts | 26 +-- test/e2e/network-interface-create.e2e.ts | 8 +- test/e2e/silos.e2e.ts | 30 ++-- 10 files changed, 134 insertions(+), 224 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 31454857c..79b85bea8 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -118,7 +118,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) ipVersionFieldName="ipVersion" pools={unicastPools} currentPool={pool} - currentIpVersion={ipVersion} setValue={form.setValue} disabled={unicastPools.length === 0} compatibleVersions={compatibleVersions} diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index bab09147e..274c9154c 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -10,8 +10,6 @@ import type { Control, UseFormSetValue } from 'react-hook-form' import type { IpVersion, SiloIpPool } from '@oxide/api' -import { Radio } from '~/ui/lib/Radio' - import { toIpPoolItem } from './ip-pool-item' import { ListboxField } from './ListboxField' @@ -20,10 +18,8 @@ type IpPoolSelectorProps = { poolFieldName: string ipVersionFieldName: string pools: SiloIpPool[] - /** Current value of the pool field - used to determine radio selection */ + /** Current value of the pool field */ currentPool: string | undefined - /** Current value of the IP version field - used to determine radio selection */ - currentIpVersion: IpVersion /** Function to update form values */ setValue: UseFormSetValue disabled?: boolean @@ -34,153 +30,72 @@ type IpPoolSelectorProps = { compatibleVersions?: IpVersion[] } -/** - * IP Pool selector with radio button pattern: - * - "IPv4 default" (if v4 default exists and is compatible) - * - "IPv6 default" (if v6 default exists and is compatible) - * - "Use custom pool" (with pool dropdown) - */ export function IpPoolSelector({ control, poolFieldName, ipVersionFieldName, pools, currentPool, - currentIpVersion, setValue, disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Determine which default pool versions exist - const hasV4Default = pools.some((p) => p.isDefault && p.ipVersion === 'v4') - const hasV6Default = pools.some((p) => p.isDefault && p.ipVersion === 'v6') - - // Filter default options by compatible versions - // undefined = no filtering, [] = filter out everything - const showV4Default = - hasV4Default && (!compatibleVersions || compatibleVersions.includes('v4')) - const showV6Default = - hasV6Default && (!compatibleVersions || compatibleVersions.includes('v6')) - - // Filter pools by compatible versions for custom pool dropdown + // Filter pools by compatible versions const filteredPools = compatibleVersions ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) : pools - // Derive current selection, ensuring it maps to a rendered option - type SelectionType = 'v4-default' | 'v6-default' | 'custom' - let currentSelection: SelectionType + // Sort pools: v4 default first, then v6 default, then others alphabetically + const sortedPools = [...filteredPools].sort((a, b) => { + // v4 default goes first + if (a.isDefault && a.ipVersion === 'v4') return -1 + if (b.isDefault && b.ipVersion === 'v4') return 1 - if (currentPool && filteredPools.some((p) => p.name === currentPool)) { - // Valid custom pool selected - currentSelection = 'custom' - } else if (!currentPool && currentIpVersion === 'v6' && showV6Default) { - // v6 default requested and available - currentSelection = 'v6-default' - } else if (!currentPool && showV4Default) { - // v4 default (explicit or fallback) - currentSelection = 'v4-default' - } else if (showV6Default) { - // Fallback to v6 default if rendered - currentSelection = 'v6-default' - } else if (showV4Default) { - // Fallback to v4 default if rendered - currentSelection = 'v4-default' - } else { - // Final fallback: custom radio is always rendered, so this is safe - currentSelection = 'custom' - } + // v6 default goes second + if (a.isDefault && a.ipVersion === 'v6') return -1 + if (b.isDefault && b.ipVersion === 'v6') return 1 - const hasNoPools = filteredPools.length === 0 && !showV4Default && !showV6Default + // All others sorted alphabetically by name + return a.name.localeCompare(b.name) + }) - const radioName = `pool-selection-type-${poolFieldName}` + const hasNoPools = filteredPools.length === 0 - // Auto-correct form state when compatibility filtering changes the selection + // Set default pool selection on mount if none selected, or if current pool is no longer valid useEffect(() => { - if (currentSelection === 'v4-default' && (currentPool || currentIpVersion !== 'v4')) { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v4') - } else if ( - currentSelection === 'v6-default' && - (currentPool || currentIpVersion !== 'v6') - ) { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v6') - } else if (currentSelection === 'custom' && !currentPool && filteredPools.length > 0) { - // Fell back to custom but no pool selected - setValue(poolFieldName, filteredPools[0].name) + if (sortedPools.length > 0) { + const currentPoolValid = currentPool && sortedPools.some((p) => p.name === currentPool) + + if (!currentPoolValid) { + // Use the first pool in the sorted list (which will be a default if one exists) + const defaultPool = sortedPools[0] + setValue(poolFieldName, defaultPool.name) + setValue(ipVersionFieldName, defaultPool.ipVersion) + } } - }, [ - currentSelection, - currentPool, - currentIpVersion, - filteredPools, - poolFieldName, - ipVersionFieldName, - setValue, - ]) + }, [currentPool, sortedPools, poolFieldName, ipVersionFieldName, setValue]) + + // Update IP version when pool changes + useEffect(() => { + if (currentPool) { + const selectedPool = sortedPools.find((p) => p.name === currentPool) + if (selectedPool) { + setValue(ipVersionFieldName, selectedPool.ipVersion) + } + } + }, [currentPool, sortedPools, ipVersionFieldName, setValue]) return (
-
- Select IP pool - {hasNoPools ? ( -
- No IP pools available for this network interface type -
- ) : ( -
- {showV4Default && ( - { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v4') - }} - disabled={disabled} - > - IPv4 default - - )} - {showV6Default && ( - { - setValue(poolFieldName, '') - setValue(ipVersionFieldName, 'v6') - }} - disabled={disabled} - > - IPv6 default - - )} - { - // Set to first compatible pool in list so the dropdown shows with a valid selection - if (filteredPools.length > 0) { - setValue(poolFieldName, filteredPools[0].name) - } - }} - disabled={disabled} - > - custom pool - -
- )} -
- - {currentSelection === 'custom' && filteredPools.length > 0 && ( + {hasNoPools ? ( +
+ No IP pools available for this network interface type +
+ ) : ( ([]) @@ -138,7 +137,6 @@ export default function CreateFloatingIpSideModalForm() { ipVersionFieldName="ipVersion" pools={unicastPools} currentPool={pool} - currentIpVersion={ipVersion} setValue={form.setValue} /> diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index e908245be..b1574e00e 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -964,7 +964,6 @@ const AdvancedAccordion = ({ ipVersionFieldName="ephemeralIpVersion" pools={unicastPools} currentPool={ephemeralIpPool} - currentIpVersion={ephemeralIpVersionField.field.value} setValue={setValue} disabled={isSubmitting} compatibleVersions={compatibleVersions} diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 7fb7fb7f8..757080864 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -29,24 +29,20 @@ test('can create a floating IP', async ({ page }) => { .fill('A description for this Floating IP') const advancedAccordion = page.getByRole('button', { name: 'Advanced' }) - const poolRadio = page.getByRole('radio', { name: 'custom pool' }) - const poolDropdown = page.getByLabel('IP pool') + const poolDropdown = page.getByLabel('Pool') // accordion content should be hidden - await expect(poolRadio).toBeHidden() + await expect(poolDropdown).toBeHidden() // open accordion await advancedAccordion.click() - // accordion content should be visible - await expect(poolRadio).toBeVisible() - - // select custom pool radio button - await poolRadio.click() - - // now the IP pool dropdown should be visible + // pool dropdown should now be visible await expect(poolDropdown).toBeVisible() + // Default pool should be selected (ip-pool-1 is the v4 default) + await expect(poolDropdown).toContainText('ip-pool-1') + // choose pool and submit await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-1' }).click() diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 9fad257e7..51338ad5f 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -75,29 +75,25 @@ test('can create an instance', async ({ page }) => { const checkbox = page.getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address', }) - const customPoolRadio = page.getByRole('radio', { name: 'custom pool' }) - const poolDropdown = page.getByLabel('IP pool') + const poolDropdown = page.getByLabel('Pool') - // verify that the ephemeral IP checkbox is checked and default radio is selected + // verify that the ephemeral IP checkbox is checked and pool dropdown is visible await expect(checkbox).toBeChecked() - // IPv4 default should be selected by default - await expect( - page.getByRole('radio', { name: 'IPv4 default', checked: true }) - ).toBeVisible() - - // select custom pool to see the dropdown - await customPoolRadio.click() 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() // unchecking the box should hide the pool selector await checkbox.uncheck() - await expect(customPoolRadio).toBeHidden() + await expect(poolDropdown).toBeHidden() // re-checking the box should re-enable the selector, and other options should be selectable await checkbox.check() - await customPoolRadio.click() // 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 @@ -847,15 +843,15 @@ test('create instance with custom IPv4-only NIC constrains ephemeral IP to IPv4' }) await expect(ephemeralCheckbox).toBeVisible() - // IPv4 default should be available - await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() - // IPv6 default should NOT be available (filtered out) - await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + // IPv4 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-1') - // Check custom pool - IPv6 pools should be filtered out - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Open dropdown to check available options - IPv6 pools should be filtered out + await poolDropdown.click() // ip-pool-1 is IPv4, should appear await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() @@ -909,15 +905,15 @@ test('create instance with custom IPv6-only NIC constrains ephemeral IP to IPv6' }) await expect(ephemeralCheckbox).toBeVisible() - // IPv6 default should be available - await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() - // IPv4 default should NOT be available (filtered out) - await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + // IPv6 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-2') - // Check custom pool - IPv4 pools should be filtered out - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Open dropdown to check available options - IPv4 pools should be filtered out + await poolDropdown.click() // ip-pool-2 is IPv6, should appear await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeVisible() @@ -971,13 +967,15 @@ test('create instance with custom dual-stack NIC allows both IPv4 and IPv6 ephem }) await expect(ephemeralCheckbox).toBeVisible() - // Both IPv4 and IPv6 defaults should be available - await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() - await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + + // IPv4 default pool should be selected by default (first in sorted order) + await expect(poolDropdown).toContainText('ip-pool-1') - // Check custom pool - both IPv4 and IPv6 pools should be available - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Open dropdown to check available options - both IPv4 and IPv6 pools should be available + await poolDropdown.click() // Both pools should appear await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeVisible() diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index bcb120dc4..4d0a42c4d 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -149,9 +149,11 @@ test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page await attachEphemeralIpButton.click() modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() - // Select custom pool radio to show the dropdown - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + // Select a different pool + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() await expect(modal).toBeHidden() @@ -333,15 +335,15 @@ test('IPv4-only instance cannot attach IPv6 ephemeral IP', async ({ page }) => { const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() - // Verify that IPv6 default radio is NOT shown (filtered out by compatibility check) - await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeHidden() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() - // Verify IPv4 default radio IS shown - await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeVisible() + // IPv4 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-1') - // Check custom pool - IPv6 pools should be filtered out - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Check pool options - IPv6 pools should be filtered out + await poolDropdown.click() // ip-pool-2 is IPv6, should not appear await expect(page.getByRole('option', { name: 'ip-pool-2' })).toBeHidden() @@ -381,15 +383,15 @@ test('IPv6-only instance cannot attach IPv4 ephemeral IP', async ({ page }) => { const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() - // Verify that IPv4 default radio is NOT shown (filtered out by compatibility check) - await expect(page.getByRole('radio', { name: 'IPv4 default' })).toBeHidden() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() - // Verify IPv6 default radio IS shown - await expect(page.getByRole('radio', { name: 'IPv6 default' })).toBeVisible() + // IPv6 default pool should be selected by default + await expect(poolDropdown).toContainText('ip-pool-2') - // Check custom pool - IPv4 pools should be filtered out - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + // Check pool options - IPv4 pools should be filtered out + await poolDropdown.click() // ip-pool-1 is IPv4, should not appear await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeHidden() @@ -471,9 +473,12 @@ test('IPv6-only instance can attach IPv6 ephemeral IP', async ({ page }) => { const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' }) await expect(modal).toBeVisible() + // Pool dropdown should be visible + const poolDropdown = page.getByLabel('Pool') + await expect(poolDropdown).toBeVisible() + // Select IPv6 pool (ip-pool-2) - await page.getByRole('radio', { name: 'custom pool' }).click() - await page.getByRole('button', { name: 'IP pool' }).click() + await poolDropdown.click() await page.getByRole('option', { name: 'ip-pool-2' }).click() await page.getByRole('button', { name: 'Attach', exact: true }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index edf56f541..078374a21 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -23,27 +23,27 @@ test('IP pool list', async ({ page }) => { await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '17 / 24', + 'IPs REMAINING': '17 / 24', }) await expectRowVisible(table, { name: 'ip-pool-2', - 'IPs Remaining': '32 / 32', + 'IPs REMAINING': '32 / 32', }) await expectRowVisible(table, { name: 'ip-pool-3', - 'IPs Remaining': '0 / 0', + 'IPs REMAINING': '0 / 0', }) await expectRowVisible(table, { name: 'ip-pool-4', - 'IPs Remaining': '18.4e18 / 18.4e18', + 'IPs REMAINING': '18.4e18 / 18.4e18', }) await expectRowVisible(table, { name: 'ip-pool-5-multicast-v4', - 'IPs Remaining': '32 / 32', + 'IPs REMAINING': '32 / 32', }) await expectRowVisible(table, { name: 'ip-pool-6-multicast-v6', - 'IPs Remaining': '18.4e18 / 18.4e18', + 'IPs REMAINING': '18.4e18 / 18.4e18', }) }) @@ -55,7 +55,7 @@ test.describe('german locale', () => { const table = page.getByRole('table') await expectRowVisible(table, { name: 'ip-pool-4', - 'IPs Remaining': '18,4e18 / 18,4e18', + 'IPs REMAINING': '18,4e18 / 18,4e18', }) }) @@ -188,8 +188,8 @@ test('IP pool create v4', async ({ page }) => { await expectRowVisible(page.getByRole('table'), { name: 'another-pool', description: 'whatever', - 'Pool type': 'multicast', - 'IPs Remaining': '0 / 0', + Type: 'multicast', + 'IPs REMAINING': '0 / 0', }) }) @@ -268,7 +268,7 @@ test('IP range validation and add', async ({ page }) => { await sidebar.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-3', - 'IPs Remaining': '1 / 1', + 'IPs REMAINING': '1 / 1', }) }) @@ -344,7 +344,7 @@ test('remove range', async ({ page }) => { await breadcrumbs.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '14 / 21', + 'IPs REMAINING': '14 / 21', }) }) @@ -353,7 +353,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => { const table = page.getByRole('table') await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '17 / 24', + 'IPs REMAINING': '17 / 24', }) // go delete a floating IP @@ -370,7 +370,7 @@ test('deleting floating IP decrements utilization', async ({ page }) => { await page.getByRole('link', { name: 'IP Pools' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - 'IPs Remaining': '18 / 24', + 'IPs REMAINING': '18 / 24', }) }) diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index 87308afee..55fdea2cd 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -36,7 +36,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await expect(sidebar).toBeHidden() const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-1', 'Private IP': '1.2.3.4' }) + await expectRowVisible(table, { name: 'nic-1', 'Private IP': 'v41.2.3.4' }) }) test('can create a NIC with a blank IP address', async ({ page }) => { @@ -78,7 +78,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { const table = page.getByRole('table', { name: 'Network interfaces' }) await expectRowVisible(table, { name: 'nic-2', - 'Private IP': expect.stringMatching(/123\.45\.68\.8\s*fd12:3456::/), + 'Private IP': expect.stringMatching(/v4123\.45\.68\.8\s*v6fd12:3456::/), }) }) @@ -104,7 +104,7 @@ test('can create a NIC with IPv6 only', async ({ page }) => { await expect(sidebar).toBeHidden() const table = page.getByRole('table', { name: 'Network interfaces' }) - await expectRowVisible(table, { name: 'nic-3', 'Private IP': '::1' }) + await expectRowVisible(table, { name: 'nic-3', 'Private IP': 'v6::1' }) }) test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { @@ -131,6 +131,6 @@ test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { const table = page.getByRole('table', { name: 'Network interfaces' }) await expectRowVisible(table, { name: 'nic-4', - 'Private IP': expect.stringMatching(/10\.0\.0\.5\s*fd00::5/), + 'Private IP': expect.stringMatching(/v410\.0\.0\.5\s*v6fd00::5/), }) }) diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index f1b5ab3c1..dbb2f7303 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -265,16 +265,16 @@ test('Silo IP pools', async ({ page }) => { const table = page.getByRole('table') // Both unicast pools start as default (one IPv4, one IPv6) - valid dual-default scenario - await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) - await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) + await expectRowVisible(table, { name: 'ip-pool-1default', Version: 'v4' }) + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) // Multicast pools are also linked as defaults await expectRowVisible(table, { - name: 'ip-pool-5-multicast-v4', - 'IP Version': 'v4default', + name: 'ip-pool-5-multicast-v4default', + Version: 'v4', }) await expectRowVisible(table, { - name: 'ip-pool-6-multicast-v6', - 'IP Version': 'v6default', + name: 'ip-pool-6-multicast-v6default', + Version: 'v6', }) await expect(table.getByRole('row')).toHaveCount(5) // header + 4 @@ -293,7 +293,7 @@ test('Silo IP pools', async ({ page }) => { await page.getByRole('button', { name: 'Confirm' }).click() await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeHidden() // ip-pool-2 should still be default - await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) // clear default for IPv6 pool await clickRowAction(page, 'ip-pool-2', 'Clear default') @@ -303,7 +303,7 @@ test('Silo IP pools', async ({ page }) => { .getByText('Are you sure you want ip-pool-2 to stop being the default') ).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6' }) + await expectRowVisible(table, { name: 'ip-pool-2', Version: 'v6' }) }) test('Silo IP pools link pool', async ({ page }) => { @@ -311,16 +311,16 @@ test('Silo IP pools link pool', async ({ page }) => { const table = page.getByRole('table') // Both unicast pools start as default (one IPv4, one IPv6) - await expectRowVisible(table, { name: 'ip-pool-1', 'IP Version': 'v4default' }) - await expectRowVisible(table, { name: 'ip-pool-2', 'IP Version': 'v6default' }) + await expectRowVisible(table, { name: 'ip-pool-1default', Version: 'v4' }) + await expectRowVisible(table, { name: 'ip-pool-2default', Version: 'v6' }) // Multicast pools are also linked await expectRowVisible(table, { - name: 'ip-pool-5-multicast-v4', - 'IP Version': 'v4default', + name: 'ip-pool-5-multicast-v4default', + Version: 'v4', }) await expectRowVisible(table, { - name: 'ip-pool-6-multicast-v6', - 'IP Version': 'v6default', + name: 'ip-pool-6-multicast-v6default', + Version: 'v6', }) await expect(table.getByRole('row')).toHaveCount(5) // header + 4 @@ -350,7 +350,7 @@ test('Silo IP pools link pool', async ({ page }) => { // modal closes and we see the thing in the table await expect(modal).toBeHidden() - await expectRowVisible(table, { name: 'ip-pool-3', 'IP Version': 'v4' }) + await expectRowVisible(table, { name: 'ip-pool-3', Version: 'v4' }) }) // just a convenient form to test this with because it's tall From 10c0f705a89b50d0b5ad5b31b0c43657449a15a5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 07:58:50 -0800 Subject: [PATCH 54/73] npm run fmt --- app/components/form/fields/IpPoolSelector.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 274c9154c..f8ec8b5d7 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -64,7 +64,8 @@ export function IpPoolSelector({ // Set default pool selection on mount if none selected, or if current pool is no longer valid useEffect(() => { if (sortedPools.length > 0) { - const currentPoolValid = currentPool && sortedPools.some((p) => p.name === currentPool) + const currentPoolValid = + currentPool && sortedPools.some((p) => p.name === currentPool) if (!currentPoolValid) { // Use the first pool in the sorted list (which will be a default if one exists) From 482e82d59473413cfa5638dd486a4b922a767aa5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 09:14:33 -0800 Subject: [PATCH 55/73] Fix unrelated test --- test/e2e/instance-create.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 51338ad5f..01fa64daa 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -319,6 +319,8 @@ test('add ssh key from instance create form', async ({ page }) => { // pop over to the real SSH keys page and see it there, why not await page.getByLabel('User menu').click() await page.getByRole('menuitem', { name: 'Settings' }).click() + // Confirm navigation away from the form with unsaved changes + await page.getByRole('button', { name: 'Leave this page' }).click() await page.getByRole('link', { name: 'SSH Keys' }).click() await expectRowVisible(page.getByRole('table'), { name: newKey, description: 'hi' }) }) From 01f4e435eaf07c966981fa986a8de25725557070 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 10:14:02 -0800 Subject: [PATCH 56/73] Better handle default / undefined data --- app/components/form/fields/IpPoolSelector.tsx | 7 ++++--- app/forms/ip-pool-range-add.tsx | 6 +++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index f8ec8b5d7..3d7bc2789 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -67,9 +67,10 @@ export function IpPoolSelector({ const currentPoolValid = currentPool && sortedPools.some((p) => p.name === currentPool) - if (!currentPoolValid) { - // Use the first pool in the sorted list (which will be a default if one exists) - const defaultPool = sortedPools[0] + // Only auto-select when there's an actual default pool + const defaultPool = sortedPools.find((p) => p.isDefault) + + if (!currentPoolValid && defaultPool) { setValue(poolFieldName, defaultPool.name) setValue(ipVersionFieldName, defaultPool.ipVersion) } diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index ee345ad19..f8c878eb0 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -88,7 +88,6 @@ export default function IpPoolAddRange() { const navigate = useNavigate() const { data: poolData } = usePrefetchedQuery(q(api.ipPoolView, { path: { pool } })) - const poolVersion = poolData.ipVersion const onDismiss = () => navigate(pb.ipPool({ pool })) @@ -102,8 +101,13 @@ export default function IpPoolAddRange() { }, }) + // poolData can be undefined briefly; use v4 as fallback until data arrives + const poolVersion = poolData?.ipVersion || 'v4' const form = useForm({ defaultValues, resolver: createResolver(poolVersion) }) + // Guard against undefined poolData during initial load + if (!poolData) return null + return ( Date: Wed, 28 Jan 2026 10:22:37 -0800 Subject: [PATCH 57/73] smarter mock exception handling --- mock-api/msw/db.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 648ddac52..0419c2498 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -93,6 +93,22 @@ export const resolvePoolSelector = ( return true }) + // Enforce API requirement: when type is 'auto' and ip_version is unset, + // reject if multiple default pools exist (ambiguous) + if ( + poolSelector?.type === 'auto' && + !poolSelector.ip_version && + candidateLinks.length > 1 + ) { + throw json( + { + error_code: 'InvalidRequest', + message: 'ip_version required when multiple default pools exist', + }, + { status: 400 } + ) + } + const link = candidateLinks[0] if (!link) { const typeStr = poolType ? ` ${poolType}` : '' From a359a402914ba4452b06804bb6077df3e32ddd3d Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 10:43:09 -0800 Subject: [PATCH 58/73] Add logic to determine v4/v6 NICs during create flow --- mock-api/msw/handlers.ts | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 1f2a6ab34..629dbd370 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -534,12 +534,34 @@ export const handlers = makeHandlers({ // validate floating IP attachments before we actually do anything // Determine what IP stacks the instance will have based on network interfaces - const hasIpv4Nic = - body.network_interfaces?.type === 'default_ipv4' || - body.network_interfaces?.type === 'default_dual_stack' - const hasIpv6Nic = - body.network_interfaces?.type === 'default_ipv6' || - body.network_interfaces?.type === 'default_dual_stack' + let hasIpv4Nic = false + let hasIpv6Nic = false + + const nicType = body.network_interfaces?.type + if (nicType === 'default_ipv4') { + hasIpv4Nic = true + } else if (nicType === 'default_ipv6') { + hasIpv6Nic = true + } else if (nicType === 'default_dual_stack') { + hasIpv4Nic = true + hasIpv6Nic = true + } else if (nicType === 'create' && body.network_interfaces) { + // Derive from the first NIC's ip_config (first NIC becomes primary) + const primaryNicConfig = body.network_interfaces.params[0]?.ip_config + if (primaryNicConfig?.type === 'v4') { + hasIpv4Nic = true + } else if (primaryNicConfig?.type === 'v6') { + hasIpv6Nic = true + } else if (primaryNicConfig?.type === 'dual_stack') { + hasIpv4Nic = true + hasIpv6Nic = true + } else { + // ip_config not provided = defaults to dual-stack + hasIpv4Nic = true + hasIpv6Nic = true + } + } + // If nicType is 'none' or undefined, both remain false body.external_ips?.forEach((ip) => { if (ip.type === 'floating') { From 26dcc5ec37885a89c2fdf094a52f6e28cbe53132 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 12:25:07 -0800 Subject: [PATCH 59/73] Use available pools to determine default NIC type on instance create --- .../form/fields/NetworkInterfaceField.tsx | 9 +++++++- app/forms/instance-create.tsx | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 6893c6215..73e5b083a 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -66,10 +66,17 @@ export function NetworkInterfaceField({ }} disabled={disabled} > - Default IPv4 & IPv6 + {/* + Pre-selected default based on available IP pools, set in instance-create. + If both v4 and v6, default_dual_stack will be selected. + If only v4, default_ipv4 will be selected. + If only v6, default_ipv6 will be selected. + */} Default IPv4 Default IPv6 + Default IPv4 & IPv6 None + {/* Custom follows None because of `Add network interface` button and table */} Custom {value.type === 'create' && ( diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index b1574e00e..d0dc6f9fe 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -164,7 +164,7 @@ const baseDefaultValues: InstanceCreateInput = { diskSource: '', otherDisks: [], - networkInterfaces: { type: 'default_dual_stack' }, + networkInterfaces: { type: 'default_ipv4' }, sshPublicKeys: [], @@ -188,6 +188,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { queryClient.prefetchQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ), + queryClient.prefetchQuery(q(api.vpcList, { query: { project, limit: ALL_ISH } })), ]) return null } @@ -250,11 +251,30 @@ export default function CreateInstanceForm() { // Use default version if available; fall back to v4 const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' + // Check if VPCs exist to determine default network interface type + const { data: vpcs } = usePrefetchedQuery( + q(api.vpcList, { query: { project, limit: ALL_ISH } }) + ) + const hasVpcs = vpcs.items.length > 0 + + // Determine default network interface type: + // - If VPCs exist: default to IPv4 (or IPv6 if only v6 default pool exists, or dual-stack if both) + // - If no VPCs exist: default to 'none' (user must create VPC first or use custom NICs) + const defaultNetworkInterfaceType: InstanceCreateInput['networkInterfaces']['type'] = + hasVpcs + ? hasV4Default && hasV6Default + ? 'default_dual_stack' + : hasV6Default && !hasV4Default + ? 'default_ipv6' + : 'default_ipv4' + : 'none' + const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' const defaultValues: InstanceCreateInput = { ...baseDefaultValues, + networkInterfaces: { type: defaultNetworkInterfaceType }, bootDiskSourceType: defaultSource, sshPublicKeys: allKeys, bootDiskSize: diskSizeNearest10(defaultImage?.size / GiB), From 1a5449ac5691ed92ab18b1bd6cde8258c3c0e002 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 13:13:03 -0800 Subject: [PATCH 60/73] refactor logic around determining NIC and pool availability --- app/api/util.ts | 31 ++++++++++ app/components/AttachEphemeralIpModal.tsx | 42 +++++++++----- app/components/form/fields/IpPoolSelector.tsx | 8 +-- app/forms/instance-create.tsx | 56 +++++++++++++------ 4 files changed, 100 insertions(+), 37 deletions(-) diff --git a/app/api/util.ts b/app/api/util.ts index 527edf756..547802e6f 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -17,6 +17,8 @@ import type { DiskType, Instance, InstanceState, + IpPoolType, + IpVersion, Measurement, SiloUtilization, Sled, @@ -93,6 +95,35 @@ export const genName = (...parts: [string, ...string[]]) => { ) } +/** + * Filter IP pools by compatible IP versions and pool type. + * + * @param pools - Array of IP pools to filter + * @param compatibleVersions - Optional array of compatible IP versions (v4/v6). + * If undefined, all versions are considered compatible. + * If empty array, no pools are compatible. + * @param poolType - Optional pool type filter (unicast/multicast). + * If undefined, all pool types are included. + * @returns Filtered array of pools matching the compatibility criteria + */ +export function getCompatiblePools< + T extends { ipVersion: IpVersion; poolType: IpPoolType }, +>(pools: T[], compatibleVersions?: IpVersion[], poolType?: IpPoolType): T[] { + return pools.filter((pool) => { + // Filter by pool type if specified + if (poolType !== undefined && pool.poolType !== poolType) { + return false + } + + // Filter by compatible IP versions if specified + if (compatibleVersions !== undefined && !compatibleVersions.includes(pool.ipVersion)) { + return false + } + + return true + }) +} + const instanceActions = { // NoVmm maps to to Stopped: // https://github.com/oxidecomputer/omicron/blob/6dd9802/nexus/db-model/src/instance_state.rs#L55 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 79b85bea8..17436e927 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form' import { api, + getCompatiblePools, q, queryClient, useApiMutation, @@ -36,13 +37,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Only unicast pools can be used for ephemeral IPs const unicastPools = useMemo(() => { if (!siloPools) return [] - return siloPools.items.filter((p) => p.poolType === 'unicast') + return getCompatiblePools(siloPools.items, undefined, 'unicast') }, [siloPools]) - const hasDefaultUnicastPool = useMemo(() => { - return unicastPools.some((p) => p.isDefault) - }, [unicastPools]) - // Determine compatible IP versions based on instance's primary network interface // External IPs route through the primary interface, so only its IP stack matters // https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L544-L661 @@ -66,6 +63,15 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return versions }, [nics]) + // Filter unicast pools by compatible IP versions + const compatibleUnicastPools = useMemo(() => { + return getCompatiblePools(unicastPools, compatibleVersions) + }, [unicastPools, compatibleVersions]) + + const hasDefaultCompatiblePool = useMemo(() => { + return compatibleUnicastPools.some((p) => p.isDefault) + }, [compatibleUnicastPools]) + const instanceEphemeralIpAttach = useApiMutation(api.instanceEphemeralIpAttach, { onSuccess(ephemeralIp) { queryClient.invalidateEndpoint('instanceExternalIpList') @@ -100,9 +106,11 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) if (compatibleVersions && compatibleVersions.length === 0) { return 'Instance has no network interfaces with compatible IP stacks' } - if (unicastPools.length === 0) return 'No unicast pools available' - if (!pool && !hasDefaultUnicastPool) { - return 'No default pool available; select a pool to continue' + if (compatibleUnicastPools.length === 0) { + return 'No compatible unicast pools available for this instance' + } + if (!pool && !hasDefaultCompatiblePool) { + return 'No default compatible pool available; select a pool to continue' } return undefined } @@ -116,10 +124,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) control={form.control} poolFieldName="pool" ipVersionFieldName="ipVersion" - pools={unicastPools} + pools={compatibleUnicastPools} currentPool={pool} setValue={form.setValue} - disabled={unicastPools.length === 0} + disabled={compatibleUnicastPools.length === 0} compatibleVersions={compatibleVersions} /> @@ -131,16 +139,20 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) !siloPools || !nics || (compatibleVersions && compatibleVersions.length === 0) || - unicastPools.length === 0 || - (!pool && !hasDefaultUnicastPool) + compatibleUnicastPools.length === 0 || + (!pool && !hasDefaultCompatiblePool) } disabledReason={getDisabledReason()} onAction={() => { - // When using default pool, derive ipVersion from available defaults + // When using default pool, derive ipVersion from available compatible defaults let effectiveIpVersion = ipVersion if (!pool) { - const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') - const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + const v4Default = compatibleUnicastPools.find( + (p) => p.isDefault && p.ipVersion === 'v4' + ) + const v6Default = compatibleUnicastPools.find( + (p) => p.isDefault && p.ipVersion === 'v6' + ) // If only one default exists, use that version if (v4Default && !v6Default) { diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 3d7bc2789..326e204b4 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -8,7 +8,7 @@ import { useEffect } from 'react' import type { Control, UseFormSetValue } from 'react-hook-form' -import type { IpVersion, SiloIpPool } from '@oxide/api' +import { getCompatiblePools, type IpVersion, type SiloIpPool } from '@oxide/api' import { toIpPoolItem } from './ip-pool-item' import { ListboxField } from './ListboxField' @@ -40,10 +40,8 @@ export function IpPoolSelector({ disabled = false, compatibleVersions, }: IpPoolSelectorProps) { - // Filter pools by compatible versions - const filteredPools = compatibleVersions - ? pools.filter((p) => compatibleVersions.includes(p.ipVersion)) - : pools + // Note: pools are already filtered by poolType before being passed to this component + const filteredPools = getCompatiblePools(pools, compatibleVersions) // Sort pools: v4 default first, then v6 default, then others alphabetically const sortedPools = [...filteredPools].sort((a, b) => { diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index d0dc6f9fe..42cee12db 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -21,6 +21,7 @@ import { api, diskCan, genName, + getCompatiblePools, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, q, @@ -238,7 +239,7 @@ export default function CreateInstanceForm() { // Only unicast pools can be used for ephemeral IPs const unicastPools = useMemo( - () => siloPools?.items.filter((p) => p.poolType === 'unicast') || [], + () => getCompatiblePools(siloPools?.items || [], undefined, 'unicast'), [siloPools] ) @@ -849,22 +850,29 @@ const AdvancedAccordion = ({ return versions }, [networkInterfaces]) + // Filter unicast pools by compatible IP versions + const compatibleUnicastPools = useMemo(() => { + return getCompatiblePools(unicastPools, compatibleVersions) + }, [unicastPools, compatibleVersions]) + // Track previous compatible NICs state to detect transitions const prevHasNicsRef = useRef(undefined) - // Automatically manage ephemeral IP based on NIC availability + // Automatically manage ephemeral IP based on NIC and pool availability useEffect(() => { const hasNics = compatibleVersions && compatibleVersions.length > 0 + const hasPools = compatibleUnicastPools.length > 0 + const canAttach = hasNics && hasPools const prevHasNics = prevHasNicsRef.current - if (!hasNics && assignEphemeralIp) { - // Remove ephemeral IP when there are no compatible NICs + if (!canAttach && assignEphemeralIp) { + // Remove ephemeral IP when there are no compatible NICs or pools const newExternalIps = externalIps.field.value?.filter( (ip) => ip.type !== 'ephemeral' ) externalIps.field.onChange(newExternalIps) - } else if (hasNics && !prevHasNics && !assignEphemeralIp) { - // Add ephemeral IP only when transitioning from no NICs to having NICs + } else if (canAttach && !prevHasNics && !assignEphemeralIp) { + // Add ephemeral IP only when transitioning from no NICs/pools to having both // (prevHasNics === false means we had no NICs before) externalIps.field.onChange([ ...(externalIps.field.value || []), @@ -873,7 +881,7 @@ const AdvancedAccordion = ({ } prevHasNicsRef.current = hasNics - }, [compatibleVersions, assignEphemeralIp, externalIps]) + }, [compatibleVersions, compatibleUnicastPools, assignEphemeralIp, externalIps]) // Update ephemeralIpVersion when compatibleVersions changes useEffect(() => { @@ -951,21 +959,35 @@ const AdvancedAccordion = ({ {(() => { const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 - const disabledReason = hasCompatibleNics ? undefined : ( - <> - Add a compatible network interface -
- to attach an ephemeral IP address - - ) + const hasCompatiblePools = compatibleUnicastPools.length > 0 + const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools + + let disabledReason: React.ReactNode = undefined + if (!hasCompatibleNics) { + disabledReason = ( + <> + Add a compatible network interface +
+ to attach an ephemeral IP address + + ) + } else if (!hasCompatiblePools) { + disabledReason = ( + <> + No compatible IP pools available +
+ for this network interface type + + ) + } return ( <> }> { const newExternalIps = assignEphemeralIp ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') @@ -982,7 +1004,7 @@ const AdvancedAccordion = ({ control={control} poolFieldName="ephemeralIpPool" ipVersionFieldName="ephemeralIpVersion" - pools={unicastPools} + pools={compatibleUnicastPools} currentPool={ephemeralIpPool} setValue={setValue} disabled={isSubmitting} From 02b349b3abbb17a8143da3c98cefbdf44e6142e5 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 13:59:57 -0800 Subject: [PATCH 61/73] refactoring --- app/components/AttachEphemeralIpModal.tsx | 82 ++++---- app/components/form/fields/IpPoolSelector.tsx | 30 +-- app/forms/instance-create.tsx | 198 +++++++++--------- 3 files changed, 158 insertions(+), 152 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 17436e927..dfc4abe03 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { @@ -100,20 +100,55 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const pool = form.watch('pool') const ipVersion = form.watch('ipVersion') - const getDisabledReason = () => { - if (!siloPools) return 'Loading pools...' - if (!nics) return 'Loading network interfaces...' + const disabledState = useMemo(() => { + if (!siloPools) return { disabled: true, reason: 'Loading pools...' } + if (!nics) return { disabled: true, reason: 'Loading network interfaces...' } if (compatibleVersions && compatibleVersions.length === 0) { - return 'Instance has no network interfaces with compatible IP stacks' + return { + disabled: true, + reason: 'Instance has no network interfaces with compatible IP stacks', + } } if (compatibleUnicastPools.length === 0) { - return 'No compatible unicast pools available for this instance' + return { + disabled: true, + reason: 'No compatible unicast pools available for this instance', + } } if (!pool && !hasDefaultCompatiblePool) { - return 'No default compatible pool available; select a pool to continue' + return { + disabled: true, + reason: 'No default compatible pool available; select a pool to continue', + } } - return undefined - } + return { disabled: false, reason: undefined } + }, [ + siloPools, + nics, + compatibleVersions, + compatibleUnicastPools, + pool, + hasDefaultCompatiblePool, + ]) + + const getEffectiveIpVersion = useCallback(() => { + // If explicit pool selected, use form's ipVersion + if (pool) return ipVersion + + const v4Default = compatibleUnicastPools.find( + (p) => p.isDefault && p.ipVersion === 'v4' + ) + const v6Default = compatibleUnicastPools.find( + (p) => p.isDefault && p.ipVersion === 'v6' + ) + + // If only one default exists, use that version + if (v4Default && !v6Default) return 'v4' + if (v6Default && !v4Default) return 'v6' + + // If both exist, use form's ipVersion (user's choice) + return ipVersion + }, [pool, ipVersion, compatibleUnicastPools]) return ( @@ -135,33 +170,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) { - // When using default pool, derive ipVersion from available compatible defaults - let effectiveIpVersion = ipVersion - if (!pool) { - const v4Default = compatibleUnicastPools.find( - (p) => p.isDefault && p.ipVersion === 'v4' - ) - const v6Default = compatibleUnicastPools.find( - (p) => p.isDefault && p.ipVersion === 'v6' - ) - - // If only one default exists, use that version - if (v4Default && !v6Default) { - effectiveIpVersion = 'v4' - } else if (v6Default && !v4Default) { - effectiveIpVersion = 'v6' - } - // If both exist, use form's ipVersion (user's choice) - } + const effectiveIpVersion = getEffectiveIpVersion() instanceEphemeralIpAttach.mutate({ path: { instance }, diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 326e204b4..9ea19f7f9 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -5,7 +5,7 @@ * * Copyright Oxide Computer Company */ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import type { Control, UseFormSetValue } from 'react-hook-form' import { getCompatiblePools, type IpVersion, type SiloIpPool } from '@oxide/api' @@ -41,23 +41,23 @@ export function IpPoolSelector({ compatibleVersions, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component - const filteredPools = getCompatiblePools(pools, compatibleVersions) + // Filter by compatible versions and sort in one pass + const sortedPools = useMemo(() => { + return getCompatiblePools(pools, compatibleVersions).sort((a, b) => { + // v4 default goes first + if (a.isDefault && a.ipVersion === 'v4') return -1 + if (b.isDefault && b.ipVersion === 'v4') return 1 - // Sort pools: v4 default first, then v6 default, then others alphabetically - const sortedPools = [...filteredPools].sort((a, b) => { - // v4 default goes first - if (a.isDefault && a.ipVersion === 'v4') return -1 - if (b.isDefault && b.ipVersion === 'v4') return 1 + // v6 default goes second + if (a.isDefault && a.ipVersion === 'v6') return -1 + if (b.isDefault && b.ipVersion === 'v6') return 1 - // v6 default goes second - if (a.isDefault && a.ipVersion === 'v6') return -1 - if (b.isDefault && b.ipVersion === 'v6') return 1 + // All others sorted alphabetically by name + return a.name.localeCompare(b.name) + }) + }, [pools, compatibleVersions]) - // All others sorted alphabetically by name - return a.name.localeCompare(b.name) - }) - - const hasNoPools = filteredPools.length === 0 + const hasNoPools = sortedPools.length === 0 // Set default pool selection on mount if none selected, or if current pool is no longer valid useEffect(() => { diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 42cee12db..982c37bb3 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -144,6 +144,35 @@ export type InstanceCreateInput = Assign< } > +/** + * Determine compatible IP versions based on network interface configuration. + * External IPs route through the primary interface, so only its IP stack matters. + */ +function getCompatibleVersionsFromNicType( + networkInterfaces: InstanceCreate['networkInterfaces'] | undefined +): IpVersion[] | undefined { + const nicType = networkInterfaces?.type + + if (nicType === 'default_ipv4') return ['v4'] + if (nicType === 'default_ipv6') return ['v6'] + if (nicType === 'default_dual_stack') return ['v4', 'v6'] + if (nicType === 'none') return [] + + if (nicType === 'create' && networkInterfaces) { + if (networkInterfaces.params.length === 0) return [] + + // Derive from the first NIC's ipConfig (first NIC becomes primary) + const primaryNicConfig = networkInterfaces.params[0].ipConfig + if (primaryNicConfig?.type === 'v4') return ['v4'] + if (primaryNicConfig?.type === 'v6') return ['v6'] + if (primaryNicConfig?.type === 'dual_stack') return ['v4', 'v6'] + // ipConfig not provided = defaults to dual-stack + return ['v4', 'v6'] + } + + return undefined +} + const baseDefaultValues: InstanceCreateInput = { name: '', description: '', @@ -736,7 +765,10 @@ const AdvancedAccordion = ({ const ephemeralIpPool = ephemeralIpPoolField.field.value // Initialize ephemeralIpPool once on mount if externalIps already has an explicit pool + const hasInitializedPoolRef = useRef(false) useEffect(() => { + if (hasInitializedPoolRef.current) return + const initialPool = ephemeralIp?.poolSelector?.type === 'explicit' ? ephemeralIp.poolSelector.pool @@ -744,9 +776,8 @@ const AdvancedAccordion = ({ if (initialPool && !ephemeralIpPool) { ephemeralIpPoolField.field.onChange(initialPool) } - // Only run on mount - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + hasInitializedPoolRef.current = true + }, [ephemeralIp, ephemeralIpPool, ephemeralIpPoolField]) // Update externalIps when ephemeralIpPool or ephemeralIpVersion changes useEffect(() => { @@ -807,48 +838,10 @@ const AdvancedAccordion = ({ .filter((ip) => !!ip) // Calculate compatible IP versions based on NIC type - const compatibleVersions: IpVersion[] | undefined = useMemo(() => { - const nicType = networkInterfaces?.type - let versions: IpVersion[] | undefined = undefined - - // Set constraints based on primary NIC configuration - if (nicType === 'default_ipv4') { - versions = ['v4'] - } else if (nicType === 'default_ipv6') { - versions = ['v6'] - } else if (nicType === 'default_dual_stack') { - versions = ['v4', 'v6'] - } else if ( - nicType === 'create' && - networkInterfaces && - networkInterfaces.params.length > 0 - ) { - // Derive from the first NIC's ipConfig (first NIC becomes primary) - const primaryNicConfig = networkInterfaces.params[0].ipConfig - if (primaryNicConfig?.type === 'v4') { - versions = ['v4'] - } else if (primaryNicConfig?.type === 'v6') { - versions = ['v6'] - } else if (primaryNicConfig?.type === 'dual_stack') { - versions = ['v4', 'v6'] - } else { - // ipConfig not provided = defaults to dual-stack - versions = ['v4', 'v6'] - } - } else if (nicType === 'none') { - // Instance has no NICs, cannot attach external IPs - versions = [] - } else if ( - nicType === 'create' && - networkInterfaces && - networkInterfaces.params.length === 0 - ) { - // Custom NICs selected but none added yet, cannot attach external IPs - versions = [] - } - - return versions - }, [networkInterfaces]) + const compatibleVersions: IpVersion[] | undefined = useMemo( + () => getCompatibleVersionsFromNicType(networkInterfaces), + [networkInterfaces] + ) // Filter unicast pools by compatible IP versions const compatibleUnicastPools = useMemo(() => { @@ -894,6 +887,33 @@ const AdvancedAccordion = ({ } }, [compatibleVersions, ephemeralIpVersionField.field.value, setValue]) + const ephemeralIpCheckboxState = useMemo(() => { + const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 + const hasCompatiblePools = compatibleUnicastPools.length > 0 + const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools + + let disabledReason: React.ReactNode = undefined + if (!hasCompatibleNics) { + disabledReason = ( + <> + Add a compatible network interface +
+ to attach an ephemeral IP address + + ) + } else if (!hasCompatiblePools) { + disabledReason = ( + <> + No compatible IP pools available +
+ for this network interface type + + ) + } + + return { canAttachEphemeralIp, disabledReason } + }, [compatibleVersions, compatibleUnicastPools]) + const closeFloatingIpModal = () => { setFloatingIpModalOpen(false) setSelectedFloatingIp(undefined) @@ -957,63 +977,37 @@ const AdvancedAccordion = ({ - {(() => { - const hasCompatibleNics = compatibleVersions && compatibleVersions.length > 0 - const hasCompatiblePools = compatibleUnicastPools.length > 0 - const canAttachEphemeralIp = hasCompatibleNics && hasCompatiblePools - - let disabledReason: React.ReactNode = undefined - if (!hasCompatibleNics) { - disabledReason = ( - <> - Add a compatible network interface -
- to attach an ephemeral IP address - - ) - } else if (!hasCompatiblePools) { - disabledReason = ( - <> - No compatible IP pools available -
- for this network interface type - - ) - } - - return ( - <> - }> - { - const newExternalIps = assignEphemeralIp - ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') - : [...(externalIps.field.value || []), { type: 'ephemeral' }] - externalIps.field.onChange(newExternalIps) - // The useEffect will update the poolSelector based on current form values - }} - > - Allocate and attach an ephemeral IP address - - - {assignEphemeralIp && ( - - )} - - ) - })()} + } + > + { + const newExternalIps = assignEphemeralIp + ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral') + : [...(externalIps.field.value || []), { type: 'ephemeral' }] + externalIps.field.onChange(newExternalIps) + // The useEffect will update the poolSelector based on current form values + }} + > + Allocate and attach an ephemeral IP address + + + {assignEphemeralIp && ( + + )}
From cd6b3ef3ef8603566df547e990c36d4dddd88fa7 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 14:28:49 -0800 Subject: [PATCH 62/73] more cleanup --- app/components/AttachEphemeralIpModal.tsx | 17 ++++------------- app/components/form/fields/IpPoolSelector.tsx | 4 ---- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index dfc4abe03..cadaf05fd 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -34,12 +34,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) q(api.instanceNetworkInterfaceList, { query: { project, instance } }) ) - // Only unicast pools can be used for ephemeral IPs - const unicastPools = useMemo(() => { - if (!siloPools) return [] - return getCompatiblePools(siloPools.items, undefined, 'unicast') - }, [siloPools]) - // Determine compatible IP versions based on instance's primary network interface // External IPs route through the primary interface, so only its IP stack matters // https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L544-L661 @@ -50,7 +44,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const nicItems = nics.items const primaryNic = nicItems.find((nic) => nic.primary) - // If no primary NIC found (defensive), return empty array if (!primaryNic) return [] const versions: IpVersion[] = [] @@ -63,10 +56,11 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) return versions }, [nics]) - // Filter unicast pools by compatible IP versions + // Only unicast pools can be used for ephemeral IPs const compatibleUnicastPools = useMemo(() => { - return getCompatiblePools(unicastPools, compatibleVersions) - }, [unicastPools, compatibleVersions]) + if (!siloPools) return [] + return getCompatiblePools(siloPools.items, compatibleVersions, 'unicast') + }, [siloPools, compatibleVersions]) const hasDefaultCompatiblePool = useMemo(() => { return compatibleUnicastPools.some((p) => p.isDefault) @@ -132,7 +126,6 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) ]) const getEffectiveIpVersion = useCallback(() => { - // If explicit pool selected, use form's ipVersion if (pool) return ipVersion const v4Default = compatibleUnicastPools.find( @@ -142,11 +135,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) (p) => p.isDefault && p.ipVersion === 'v6' ) - // If only one default exists, use that version if (v4Default && !v6Default) return 'v4' if (v6Default && !v4Default) return 'v6' - // If both exist, use form's ipVersion (user's choice) return ipVersion }, [pool, ipVersion, compatibleUnicastPools]) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 9ea19f7f9..e266e19c4 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -41,18 +41,14 @@ export function IpPoolSelector({ compatibleVersions, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component - // Filter by compatible versions and sort in one pass const sortedPools = useMemo(() => { return getCompatiblePools(pools, compatibleVersions).sort((a, b) => { - // v4 default goes first if (a.isDefault && a.ipVersion === 'v4') return -1 if (b.isDefault && b.ipVersion === 'v4') return 1 - // v6 default goes second if (a.isDefault && a.ipVersion === 'v6') return -1 if (b.isDefault && b.ipVersion === 'v6') return 1 - // All others sorted alphabetically by name return a.name.localeCompare(b.name) }) }, [pools, compatibleVersions]) From 5b7b84f938bd6463212621b974b24aba8b9db443 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 16:53:06 -0800 Subject: [PATCH 63/73] Disable NICs when no VPCs --- .../form/fields/NetworkInterfaceField.tsx | 20 +++++-- app/forms/instance-create.tsx | 41 +++++++++++-- app/ui/lib/RadioGroup.tsx | 3 +- mock-api/msw/db.ts | 3 +- mock-api/project.ts | 10 +++- test/e2e/instance-create.e2e.ts | 60 +++++++++++++++++++ 6 files changed, 125 insertions(+), 12 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 73e5b083a..7b21d54fb 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -25,9 +25,11 @@ import { RadioGroup } from '~/ui/lib/RadioGroup' export function NetworkInterfaceField({ control, disabled, + hasVpcs, }: { control: Control disabled: boolean + hasVpcs: boolean }) { const [showForm, setShowForm] = useState(false) @@ -51,6 +53,7 @@ export function NetworkInterfaceField({ column className="pt-1" defaultChecked={value.type} + disabled={disabled} onChange={(event) => { const newType = event.target.value @@ -64,7 +67,6 @@ export function NetworkInterfaceField({ onChange({ type: newType as typeof value.type }) } }} - disabled={disabled} > {/* Pre-selected default based on available IP pools, set in instance-create. @@ -72,12 +74,20 @@ export function NetworkInterfaceField({ If only v4, default_ipv4 will be selected. If only v6, default_ipv6 will be selected. */} - Default IPv4 - Default IPv6 - Default IPv4 & IPv6 + + Default IPv4 + + + Default IPv6 + + + Default IPv4 & IPv6 + None {/* Custom follows None because of `Add network interface` button and table */} - Custom + + Custom + {value.type === 'create' && ( <> diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 982c37bb3..646f4ab45 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -14,7 +14,7 @@ import { type Control, type UseFormSetValue, } from 'react-hook-form' -import { useNavigate, type LoaderFunctionArgs } from 'react-router' +import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' import type { SetRequired } from 'type-fest' import { @@ -702,6 +702,7 @@ export default function CreateInstanceForm() { isSubmitting={isSubmitting} unicastPools={unicastPools} networkInterfaces={networkInterfaces} + hasVpcs={hasVpcs} setValue={setValue} /> @@ -740,12 +741,14 @@ const AdvancedAccordion = ({ isSubmitting, unicastPools, networkInterfaces, + hasVpcs, setValue, }: { control: Control isSubmitting: boolean unicastPools: Array networkInterfaces: InstanceCreate['networkInterfaces'] + hasVpcs: boolean setValue: UseFormSetValue }) => { // we track this state manually for the sole reason that we need to be able to @@ -956,7 +959,23 @@ const AdvancedAccordion = ({ label="Networking" isOpen={openItems.includes('networking')} > - + {!hasVpcs && ( + + A VPC is required to add network interfaces.{' '} + Create a VPC to enable networking. + + } + /> + )} +
+ A network interface is required +
+ to attach a floating IP + + ) : availableFloatingIps.length === 0 ? ( + 'No floating IPs available' + ) : undefined + } onClick={() => setFloatingIpModalOpen(true)} > Attach floating IP diff --git a/app/ui/lib/RadioGroup.tsx b/app/ui/lib/RadioGroup.tsx index c1f76de5f..2f956d8cf 100644 --- a/app/ui/lib/RadioGroup.tsx +++ b/app/ui/lib/RadioGroup.tsx @@ -96,7 +96,8 @@ export const RadioGroup = ({ React.cloneElement(radio, { name, required, - disabled, + // Merge disabled: RadioGroup disabled OR individual Radio disabled + disabled: disabled || radio.props.disabled, defaultChecked: radio.props.value === defaultChecked ? true : undefined, }) )} diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 0419c2498..101185602 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -18,6 +18,7 @@ import type * as Sel from '~/api/selectors' import { commaSeries } from '~/util/str' import type { Json } from '../json-type' +import { projects } from '../project' import { defaultSilo, siloSettings } from '../silo' import { internalError } from './util' @@ -557,7 +558,7 @@ const initDb = { ipPoolRanges: [...mock.ipPoolRanges], networkInterfaces: [mock.networkInterface], physicalDisks: [...mock.physicalDisks], - projects: [...mock.projects], + projects: [...projects], racks: [...mock.racks], roleAssignments: [...mock.roleAssignments], scimTokens: [...mock.scimTokens], diff --git a/mock-api/project.ts b/mock-api/project.ts index f514d7606..1f7eb2ae0 100644 --- a/mock-api/project.ts +++ b/mock-api/project.ts @@ -26,7 +26,15 @@ export const project2: Json = { time_modified: new Date(2021, 0, 16).toISOString(), } -export const projects: Json = [project, project2] +export const projectNoVpcs: Json = { + id: 'f8a5c3d2-9b1e-4f7a-8c2d-3e4b5f6a7c8d', + name: 'project-no-vpcs', + description: 'a project with no VPCs for testing', + time_created: new Date(2021, 0, 20).toISOString(), + time_modified: new Date(2021, 0, 21).toISOString(), +} + +export const projects: Json = [project, project2, projectNoVpcs] export const projectRolePolicy: Json = { role_assignments: [ diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 01fa64daa..451db7f02 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1064,3 +1064,63 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) await expect(ephemeralCheckbox).not.toBeChecked() await expect(ephemeralCheckbox).toBeDisabled() }) + +test('floating IP button disabled when no NICs configured', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + const instanceName = 'test-no-nics' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select "None" for network interface + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + await noneRadio.click() + + // Verify the "Attach floating IP" button is disabled + const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) + await expect(attachFloatingIpButton).toBeDisabled() + + // Hover to see the tooltip + await attachFloatingIpButton.hover() + await expect(page.getByText('A network interface is required')).toBeVisible() + await expect(page.getByText('to attach a floating IP')).toBeVisible() +}) + +test('network interface options disabled when no VPCs exist', async ({ page }) => { + // Use project-no-vpcs which has no VPCs by design for testing this scenario + await page.goto('/projects/project-no-vpcs/instances-new') + + const instanceName = 'test-no-vpc-instance' + await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) + await selectASiloImage(page, 'ubuntu-22-04') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Get radio button elements + const defaultIpv4Radio = page.getByRole('radio', { name: 'Default IPv4', exact: true }) + const defaultIpv6Radio = page.getByRole('radio', { name: 'Default IPv6', exact: true }) + const defaultDualStackRadio = page.getByRole('radio', { + name: 'Default IPv4 & IPv6', + exact: true, + }) + const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) + const customRadio = page.getByRole('radio', { name: 'Custom', exact: true }) + + // Verify the message is visible (indicating no VPCs) + await expect(page.getByText('A VPC is required to add network interfaces.')).toBeVisible() + await expect(page.getByRole('link', { name: 'Create a VPC' })).toBeVisible() + + // Verify Default IPv4, Default IPv6, Default Dual Stack, and Custom are disabled + await expect(defaultIpv4Radio).toBeDisabled() + await expect(defaultIpv6Radio).toBeDisabled() + await expect(defaultDualStackRadio).toBeDisabled() + await expect(customRadio).toBeDisabled() + + // Verify "None" is enabled and checked + await expect(noneRadio).toBeEnabled() + await expect(noneRadio).toBeChecked() +}) From 65faa8631877c4fa4d02ee118c9b97a4d96c218e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 17:26:11 -0800 Subject: [PATCH 64/73] Filter Floating IP options based on the available NICs on the instance --- app/api/__tests__/client.spec.tsx | 2 +- app/forms/instance-create.tsx | 22 +++-- app/pages/project/instances/NetworkingTab.tsx | 24 +++-- app/util/ip.ts | 42 ++++++++- mock-api/floating-ip.ts | 15 +++- test/e2e/instance-create.e2e.ts | 87 ++++++++++++++++++- 6 files changed, 173 insertions(+), 19 deletions(-) diff --git a/app/api/__tests__/client.spec.tsx b/app/api/__tests__/client.spec.tsx index f6ce942d1..b52c14d46 100644 --- a/app/api/__tests__/client.spec.tsx +++ b/app/api/__tests__/client.spec.tsx @@ -166,7 +166,7 @@ describe('useApiQuery', () => { const { result } = renderProjectList() await waitFor(() => { const items = result.current.data?.items - expect(items?.length).toEqual(2) + expect(items?.length).toEqual(3) expect(items?.[0].id).toEqual(project.id) }) }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 646f4ab45..ccc9fc4dc 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -88,6 +88,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' +import { filterFloatingIpsByVersion } from '~/util/ip' import { docLinks, links } from '~/util/links' import { diskSizeNearest10 } from '~/util/math' import { pb } from '~/util/path-builder' @@ -832,20 +833,25 @@ const AdvancedAccordion = ({ [floatingIpList] ) - // To find available floating IPs, we remove the ones that are already committed to this instance - const availableFloatingIps = attachableFloatingIps.filter( - (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) - ) - const attachedFloatingIpsData = attachedFloatingIps - .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) - .filter((ip) => !!ip) - // Calculate compatible IP versions based on NIC type const compatibleVersions: IpVersion[] | undefined = useMemo( () => getCompatibleVersionsFromNicType(networkInterfaces), [networkInterfaces] ) + // To find available floating IPs, we remove the ones that are already committed to this instance + // and filter by IP version compatibility with configured NICs + const availableFloatingIps = useMemo(() => { + const notAttached = attachableFloatingIps.filter( + (ip) => !attachedFloatingIps.find((attachedIp) => attachedIp.floatingIp === ip.name) + ) + return filterFloatingIpsByVersion(notAttached, compatibleVersions) + }, [attachableFloatingIps, attachedFloatingIps, compatibleVersions]) + + const attachedFloatingIpsData = attachedFloatingIps + .map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp)) + .filter((ip) => !!ip) + // Filter unicast pools by compatible IP versions const compatibleUnicastPools = useMemo(() => { return getCompatiblePools(unicastPools, compatibleVersions) diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 4fe4861e3..5b2fd4250 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -59,6 +59,7 @@ import { TableEmptyBox } from '~/ui/lib/Table' import { TipIcon } from '~/ui/lib/TipIcon' import { Tooltip } from '~/ui/lib/Tooltip' import { ALL_ISH } from '~/util/consts' +import { filterFloatingIpsByVersion, getCompatibleVersionsFromNics } from '~/util/ip' import { pb } from '~/util/path-builder' import { fancifyStates } from './common' @@ -290,8 +291,22 @@ export default function NetworkingTab() { const { data: ips } = usePrefetchedQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) - // Filter out the IPs that are already attached to an instance - const availableIps = useMemo(() => ips.items.filter((ip) => !ip.instanceId), [ips]) + + const nics = usePrefetchedQuery( + q(api.instanceNetworkInterfaceList, { + query: { ...instanceSelector, limit: ALL_ISH }, + }) + ).data.items + + // Determine compatible IP versions from the instance's primary NIC + // External IPs route through the primary interface, so only its IP stack matters + const compatibleVersions = useMemo(() => getCompatibleVersionsFromNics(nics), [nics]) + + // Filter out the IPs that are already attached to an instance and filter by IP version compatibility + const availableIps = useMemo(() => { + const notAttached = ips.items.filter((ip) => !ip.instanceId) + return filterFloatingIpsByVersion(notAttached, compatibleVersions) + }, [ips, compatibleVersions]) const createNic = useApiMutation(api.instanceNetworkInterfaceCreate, { onSuccess() { @@ -315,11 +330,6 @@ export default function NetworkingTab() { const { data: instance } = usePrefetchedQuery( q(api.instanceView, { path: { instance: instanceName }, query: { project } }) ) - const nics = usePrefetchedQuery( - q(api.instanceNetworkInterfaceList, { - query: { ...instanceSelector, limit: ALL_ISH }, - }) - ).data.items const multipleNics = nics.length > 1 diff --git a/app/util/ip.ts b/app/util/ip.ts index 039eab615..528112ebd 100644 --- a/app/util/ip.ts +++ b/app/util/ip.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import type { IpVersion } from '~/api' +import type { InstanceNetworkInterface, IpVersion } from '~/api' // Borrowed from Valibot. I tried some from Zod and an O'Reilly regex cookbook // but they didn't match results with std::net on simple test cases @@ -86,3 +86,43 @@ export function validateIpNet(ipNet: string): string | undefined { const result = parseIpNet(ipNet) if (result.type === 'error') return result.message } + +/** + * Get compatible IP versions from an instance's NICs. External IPs route + * through the primary interface, so only its IP stack matters. + */ +export function getCompatibleVersionsFromNics( + nics: InstanceNetworkInterface[] +): IpVersion[] { + const primaryNic = nics.find((nic) => nic.primary) + if (!primaryNic) return [] + + const { ipStack } = primaryNic + if (ipStack.type === 'v4' || ipStack.type === 'v6') { + return [ipStack.type] + } + if (ipStack.type === 'dual_stack') { + return ['v4', 'v6'] + } + return [] +} + +/** + * Filters floating IPs by compatible IP versions. Only IPs whose version + * matches one of the compatible versions are returned. + */ +export function filterFloatingIpsByVersion( + floatingIps: T[], + compatibleVersions: IpVersion[] | undefined +): T[] { + // If no compatible versions, no floating IPs are compatible + if (!compatibleVersions || compatibleVersions.length === 0) { + return [] + } + + return floatingIps.filter((floatingIp) => { + const ipVersion = parseIp(floatingIp.ip) + if (ipVersion.type === 'error') return false + return compatibleVersions.includes(ipVersion.type) + }) +} diff --git a/mock-api/floating-ip.ts b/mock-api/floating-ip.ts index 787bff639..aa9b00042 100644 --- a/mock-api/floating-ip.ts +++ b/mock-api/floating-ip.ts @@ -41,4 +41,17 @@ export const floatingIp2: Json = { time_modified: new Date().toISOString(), } -export const floatingIps = [floatingIp, floatingIp2] +// An IPv6 floating IP for testing IP version filtering +export const floatingIp3: Json = { + id: 'b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e', + name: 'ipv6-float', + description: 'An IPv6 address.', + instance_id: undefined, + ip: 'fd00:1122:3344:101::1', + ip_pool_id: ipPool1.id, + project_id: project.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const floatingIps = [floatingIp, floatingIp2, floatingIp3] diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index 451db7f02..b7ca0de3e 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -472,13 +472,27 @@ test('attaches a floating IP; disables button when no IPs available', async ({ p Name: floatingIp.name, IP: floatingIp.ip, }) + + // The button should still be enabled because there's still ipv6-float available + await expect(attachFloatingIpButton).toBeEnabled() + + // Attach the IPv6 floating IP too + await attachFloatingIpButton.click() + await selectFloatingIpButton.click() + await page.getByRole('option', { name: 'ipv6-float' }).click() + await attachButton.click() + + // Now the button should be disabled because both floating IPs are attached await expect(attachFloatingIpButton).toBeDisabled() - // removing the floating IP row should work, and should re-enable the "attach" button + // removing one floating IP row should work, and should re-enable the "attach" button await page.getByRole('button', { name: 'remove floating IP rootbeer-float' }).click() await expect(page.getByText(floatingIp.name)).toBeHidden() await expect(attachFloatingIpButton).toBeEnabled() + // Remove the IPv6 floating IP too + await page.getByRole('button', { name: 'remove floating IP ipv6-float' }).click() + // re-attach the floating IP await attachFloatingIpButton.click() await selectFloatingIpButton.click() @@ -1124,3 +1138,74 @@ test('network interface options disabled when no VPCs exist', async ({ page }) = await expect(noneRadio).toBeEnabled() await expect(noneRadio).toBeChecked() }) + +test('floating IPs are filtered by NIC IP version', async ({ page }) => { + await page.goto('/projects/mock-project/instances-new') + + // Open networking accordion + await page.getByRole('button', { name: 'Networking' }).click() + + // Select IPv4-only networking + await page.getByRole('radio', { name: 'Default IPv4', exact: true }).click() + + // Open the floating IP modal + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(page.getByRole('dialog', { name: 'Attach floating IP' })).toBeVisible() + + // Get the listbox and open it + const listbox = page.getByRole('button', { name: 'Floating IP', exact: true }) + await listbox.click() + + // Verify only IPv4 floating IP is available (rootbeer-float with IP 123.4.56.4) + await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible() + // IPv6 floating IP should not be in the list + await expect(page.getByRole('option', { name: 'ipv6-float' })).not.toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + const dialog = page.getByRole('dialog', { name: 'Attach floating IP' }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to IPv6-only networking + await page.getByRole('radio', { name: 'Default IPv6', exact: true }).click() + + // Open the floating IP modal again + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(dialog).toBeVisible() + + // Get the listbox and open it + await listbox.click() + + // Verify only IPv6 floating IP is available (ipv6-float) + await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible() + // IPv4 floating IP should not be in the list + await expect(page.getByRole('option', { name: 'rootbeer-float' })).not.toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to dual-stack networking + await page.getByRole('radio', { name: 'Default IPv4 & IPv6', exact: true }).click() + + // Open the floating IP modal again + await page.getByRole('button', { name: 'Attach floating IP' }).click() + + // Wait for modal to open + await expect(dialog).toBeVisible() + + // Get the listbox and open it + await listbox.click() + + // Verify both IPv4 and IPv6 floating IPs are available + await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible() + await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible() +}) From 00858af70326f70e0cb3e6fd6d78e4b12d25004f Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 17:44:35 -0800 Subject: [PATCH 65/73] update tests and mock data --- app/forms/ip-pool-range-add.tsx | 13 ++++++---- mock-api/floating-ip.ts | 11 +++++---- test/e2e/instance-create.e2e.ts | 42 ++++++++++++++------------------- test/e2e/ip-pools.e2e.ts | 2 +- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index f8c878eb0..e9b89dc9c 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { useCallback } from 'react' import { useForm, type FieldErrors } from 'react-hook-form' import { useNavigate } from 'react-router' @@ -101,9 +102,13 @@ export default function IpPoolAddRange() { }, }) - // poolData can be undefined briefly; use v4 as fallback until data arrives - const poolVersion = poolData?.ipVersion || 'v4' - const form = useForm({ defaultValues, resolver: createResolver(poolVersion) }) + // Derive pool version at validation time to ensure correct IP version rules + const resolver = useCallback( + (values: IpRange) => createResolver(poolData?.ipVersion ?? 'v4')(values), + [poolData?.ipVersion] + ) + + const form = useForm({ defaultValues, resolver }) // Guard against undefined poolData during initial load if (!poolData) return null @@ -121,7 +126,7 @@ export default function IpPoolAddRange() { > = { @@ -41,14 +42,14 @@ export const floatingIp2: Json = { time_modified: new Date().toISOString(), } -// An IPv6 floating IP for testing IP version filtering +// An IPv6 floating IP for testing IP version filtering (from ip-pool-2) export const floatingIp3: Json = { id: 'b1c2d3e4-5f6a-7b8c-9d0e-1f2a3b4c5d6e', name: 'ipv6-float', description: 'An IPv6 address.', instance_id: undefined, - ip: 'fd00:1122:3344:101::1', - ip_pool_id: ipPool1.id, + ip: 'fd00::2', + ip_pool_id: ipPool2.id, project_id: project.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts index b7ca0de3e..d11b64a07 100644 --- a/test/e2e/instance-create.e2e.ts +++ b/test/e2e/instance-create.e2e.ts @@ -1079,30 +1079,6 @@ test('ephemeral IP checkbox disabled when no NICs configured', async ({ page }) await expect(ephemeralCheckbox).toBeDisabled() }) -test('floating IP button disabled when no NICs configured', async ({ page }) => { - await page.goto('/projects/mock-project/instances-new') - - const instanceName = 'test-no-nics' - await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName) - await selectASiloImage(page, 'ubuntu-22-04') - - // Open networking accordion - await page.getByRole('button', { name: 'Networking' }).click() - - // Select "None" for network interface - const noneRadio = page.getByRole('radio', { name: 'None', exact: true }) - await noneRadio.click() - - // Verify the "Attach floating IP" button is disabled - const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) - await expect(attachFloatingIpButton).toBeDisabled() - - // Hover to see the tooltip - await attachFloatingIpButton.hover() - await expect(page.getByText('A network interface is required')).toBeVisible() - await expect(page.getByText('to attach a floating IP')).toBeVisible() -}) - test('network interface options disabled when no VPCs exist', async ({ page }) => { // Use project-no-vpcs which has no VPCs by design for testing this scenario await page.goto('/projects/project-no-vpcs/instances-new') @@ -1208,4 +1184,22 @@ test('floating IPs are filtered by NIC IP version', async ({ page }) => { // Verify both IPv4 and IPv6 floating IPs are available await expect(page.getByRole('option', { name: 'rootbeer-float' })).toBeVisible() await expect(page.getByRole('option', { name: 'ipv6-float' })).toBeVisible() + + // Close the listbox dropdown first by pressing Escape + await page.keyboard.press('Escape') + + // Close the modal + await dialog.getByRole('button', { name: 'Cancel' }).click() + + // Switch to "None" networking + await page.getByRole('radio', { name: 'None' }).click() + + // Verify the "Attach floating IP" button is disabled when no NICs are configured + const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' }) + await expect(attachFloatingIpButton).toBeDisabled() + + // Verify the disabled reason tooltip + await attachFloatingIpButton.hover() + await expect(page.getByText('A network interface is required')).toBeVisible() + await expect(page.getByText('to attach a floating IP')).toBeVisible() }) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 078374a21..e13b0db98 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -27,7 +27,7 @@ test('IP pool list', async ({ page }) => { }) await expectRowVisible(table, { name: 'ip-pool-2', - 'IPs REMAINING': '32 / 32', + 'IPs REMAINING': '31 / 32', // floatingIp3 uses one IP from this pool }) await expectRowVisible(table, { name: 'ip-pool-3', From c332fb963ddd62ac2b245658cfc872ef16bd1fb4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 17:45:05 -0800 Subject: [PATCH 66/73] update OMICRON_VERSION; no API changes --- OMICRON_VERSION | 2 +- app/api/__generated__/OMICRON_VERSION | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 25af591b1..b61e07388 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -ee290435d89e95da5cbd90e635100bed84c41cda +2b4ea30370131dc1dce31e52dc62f6f5b1e66552 diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 269380f96..ab3fa864f 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -ee290435d89e95da5cbd90e635100bed84c41cda +2b4ea30370131dc1dce31e52dc62f6f5b1e66552 From 2cc1fe381a4dd03c7dfa0de3ad549972f161970a Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 18:04:52 -0800 Subject: [PATCH 67/73] Defer to API for selecting default pool --- app/components/form/fields/IpPoolSelector.tsx | 22 +++++++++++++++---- app/forms/instance-create.tsx | 14 ++++++------ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index e266e19c4..26a6d42b2 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -28,6 +28,12 @@ type IpPoolSelectorProps = { * If not provided, both v4 and v6 are allowed */ compatibleVersions?: IpVersion[] + /** + * If true, automatically select a default pool when none is selected. + * If false, allow the field to remain empty to use API defaults. + * Default to false, to allow API to manage defaults / not send explicit values. + */ + autoSelectDefault?: boolean } export function IpPoolSelector({ @@ -39,6 +45,7 @@ export function IpPoolSelector({ setValue, disabled = false, compatibleVersions, + autoSelectDefault = false, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component const sortedPools = useMemo(() => { @@ -57,7 +64,7 @@ export function IpPoolSelector({ // Set default pool selection on mount if none selected, or if current pool is no longer valid useEffect(() => { - if (sortedPools.length > 0) { + if (sortedPools.length > 0 && autoSelectDefault) { const currentPoolValid = currentPool && sortedPools.some((p) => p.name === currentPool) @@ -69,7 +76,14 @@ export function IpPoolSelector({ setValue(ipVersionFieldName, defaultPool.ipVersion) } } - }, [currentPool, sortedPools, poolFieldName, ipVersionFieldName, setValue]) + }, [ + currentPool, + sortedPools, + poolFieldName, + ipVersionFieldName, + setValue, + autoSelectDefault, + ]) // Update IP version when pool changes useEffect(() => { @@ -93,8 +107,8 @@ export function IpPoolSelector({ items={sortedPools.map(toIpPoolItem)} label={'Pool'} control={control} - placeholder="Select a pool" - required + placeholder={autoSelectDefault ? 'Select a pool' : 'Use default pool'} + required={autoSelectDefault} disabled={disabled} /> )} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index ccc9fc4dc..cae447631 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -857,15 +857,15 @@ const AdvancedAccordion = ({ return getCompatiblePools(unicastPools, compatibleVersions) }, [unicastPools, compatibleVersions]) - // Track previous compatible NICs state to detect transitions - const prevHasNicsRef = useRef(undefined) + // Track previous ability to attach ephemeral IP to detect transitions + const prevCanAttachRef = useRef(undefined) // Automatically manage ephemeral IP based on NIC and pool availability useEffect(() => { const hasNics = compatibleVersions && compatibleVersions.length > 0 const hasPools = compatibleUnicastPools.length > 0 const canAttach = hasNics && hasPools - const prevHasNics = prevHasNicsRef.current + const prevCanAttach = prevCanAttachRef.current if (!canAttach && assignEphemeralIp) { // Remove ephemeral IP when there are no compatible NICs or pools @@ -873,16 +873,16 @@ const AdvancedAccordion = ({ (ip) => ip.type !== 'ephemeral' ) externalIps.field.onChange(newExternalIps) - } else if (canAttach && !prevHasNics && !assignEphemeralIp) { - // Add ephemeral IP only when transitioning from no NICs/pools to having both - // (prevHasNics === false means we had no NICs before) + } else if (canAttach && 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) externalIps.field.onChange([ ...(externalIps.field.value || []), { type: 'ephemeral' }, ]) } - prevHasNicsRef.current = hasNics + prevCanAttachRef.current = canAttach }, [compatibleVersions, compatibleUnicastPools, assignEphemeralIp, externalIps]) // Update ephemeralIpVersion when compatibleVersions changes From 80c76051cb543934340b51aed8146c4aa9dd0e11 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Wed, 28 Jan 2026 18:17:12 -0800 Subject: [PATCH 68/73] Default to selecting Ephemeral IP pool, rather than leaving blank, to reduce conflict when both v4 and v6 defaults exist --- app/components/form/fields/IpPoolSelector.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 26a6d42b2..5cd45aff0 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -31,7 +31,7 @@ type IpPoolSelectorProps = { /** * If true, automatically select a default pool when none is selected. * If false, allow the field to remain empty to use API defaults. - * Default to false, to allow API to manage defaults / not send explicit values. + * Default to true, to automatically select a default pool if available. */ autoSelectDefault?: boolean } @@ -45,7 +45,7 @@ export function IpPoolSelector({ setValue, disabled = false, compatibleVersions, - autoSelectDefault = false, + autoSelectDefault = true, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component const sortedPools = useMemo(() => { From 0126b5acc30334543735dbfa2aecd3dd0cb85bd4 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 29 Jan 2026 09:32:17 -0800 Subject: [PATCH 69/73] Refactor, work on tests --- app/components/AttachEphemeralIpModal.tsx | 19 +++++++++---------- app/components/form/fields/IpPoolSelector.tsx | 7 ++++++- app/forms/floating-ip-create.tsx | 15 +++++++++------ app/forms/instance-create.tsx | 19 +++++++++---------- app/util/ip.ts | 16 ++++++++++++++++ test/e2e/instance-networking.e2e.ts | 19 +++++++++++++++---- 6 files changed, 64 insertions(+), 31 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index cadaf05fd..a6333b1ec 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -18,12 +18,16 @@ import { usePrefetchedQuery, type IpVersion, } from '~/api' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { + IpPoolSelector, + type UnicastIpPool, +} from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' import { ALL_ISH } from '~/util/consts' +import { getDefaultIps } from '~/util/ip' export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => { const { project, instance } = useInstanceSelector() @@ -59,7 +63,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Only unicast pools can be used for ephemeral IPs const compatibleUnicastPools = useMemo(() => { if (!siloPools) return [] - return getCompatiblePools(siloPools.items, compatibleVersions, 'unicast') + return getCompatiblePools(siloPools.items, compatibleVersions, 'unicast') as UnicastIpPool[] }, [siloPools, compatibleVersions]) const hasDefaultCompatiblePool = useMemo(() => { @@ -128,15 +132,10 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) const getEffectiveIpVersion = useCallback(() => { if (pool) return ipVersion - const v4Default = compatibleUnicastPools.find( - (p) => p.isDefault && p.ipVersion === 'v4' - ) - const v6Default = compatibleUnicastPools.find( - (p) => p.isDefault && p.ipVersion === 'v6' - ) + const { hasV4Default, hasV6Default } = getDefaultIps(compatibleUnicastPools) - if (v4Default && !v6Default) return 'v4' - if (v6Default && !v4Default) return 'v6' + if (hasV4Default && !hasV6Default) return 'v4' + if (hasV6Default && !hasV4Default) return 'v6' return ipVersion }, [pool, ipVersion, compatibleUnicastPools]) diff --git a/app/components/form/fields/IpPoolSelector.tsx b/app/components/form/fields/IpPoolSelector.tsx index 5cd45aff0..a747dd5a1 100644 --- a/app/components/form/fields/IpPoolSelector.tsx +++ b/app/components/form/fields/IpPoolSelector.tsx @@ -13,11 +13,13 @@ import { getCompatiblePools, type IpVersion, type SiloIpPool } from '@oxide/api' import { toIpPoolItem } from './ip-pool-item' import { ListboxField } from './ListboxField' +export type UnicastIpPool = SiloIpPool & { poolType: 'unicast' } + type IpPoolSelectorProps = { control: Control poolFieldName: string ipVersionFieldName: string - pools: SiloIpPool[] + pools: UnicastIpPool[] /** Current value of the pool field */ currentPool: string | undefined /** Function to update form values */ @@ -45,6 +47,9 @@ export function IpPoolSelector({ setValue, disabled = false, compatibleVersions, + // When both a default IPv4 and default IPv6 pool exist, the component picks the + // v4 default, to reduce user confusion. The selection is easily modified later + // (both in the form and later on the instance). autoSelectDefault = true, }: IpPoolSelectorProps) { // Note: pools are already filtered by poolType before being passed to this component diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 93778f036..438299add 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -22,7 +22,10 @@ import { import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { + IpPoolSelector, + type UnicastIpPool, +} from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' @@ -30,6 +33,7 @@ import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' +import { getDefaultIps } from '~/util/ip' import { pb } from '~/util/path-builder' type FloatingIpCreateFormData = { @@ -57,7 +61,7 @@ export default function CreateFloatingIpSideModalForm() { // Only unicast pools can be used for floating IPs const unicastPools = useMemo(() => { if (!allPools) return [] - return allPools.items.filter((p) => p.poolType === 'unicast') + return allPools.items.filter((p) => p.poolType === 'unicast') as UnicastIpPool[] }, [allPools]) const projectSelector = useProjectSelector() @@ -88,13 +92,12 @@ export default function CreateFloatingIpSideModalForm() { // When using default pool, derive ipVersion from available defaults let effectiveIpVersion = ipVersion if (!pool) { - const v4Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v4') - const v6Default = unicastPools.find((p) => p.isDefault && p.ipVersion === 'v6') + const { hasV4Default, hasV6Default } = getDefaultIps(unicastPools) // If only one default exists, use that version - if (v4Default && !v6Default) { + if (hasV4Default && !hasV6Default) { effectiveIpVersion = 'v4' - } else if (v6Default && !v4Default) { + } else if (hasV6Default && !hasV4Default) { effectiveIpVersion = 'v6' } // If both exist, use form's ipVersion (user's choice) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index cae447631..a3f24aac7 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -57,7 +57,7 @@ import { } from '~/components/form/fields/DisksTableField' import { FileField } from '~/components/form/fields/FileField' import { BootDiskImageSelectField as ImageSelectField } from '~/components/form/fields/ImageSelectField' -import { IpPoolSelector } from '~/components/form/fields/IpPoolSelector' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { NetworkInterfaceField } from '~/components/form/fields/NetworkInterfaceField' import { NumberField } from '~/components/form/fields/NumberField' @@ -88,7 +88,7 @@ import { Tooltip } from '~/ui/lib/Tooltip' import { Wrap } from '~/ui/util/wrap' import { ALL_ISH } from '~/util/consts' import { readBlobAsBase64 } from '~/util/file' -import { filterFloatingIpsByVersion } from '~/util/ip' +import { filterFloatingIpsByVersion, getDefaultIps } from '~/util/ip' import { docLinks, links } from '~/util/links' import { diskSizeNearest10 } from '~/util/math' import { pb } from '~/util/path-builder' @@ -269,16 +269,15 @@ export default function CreateInstanceForm() { // Only unicast pools can be used for ephemeral IPs const unicastPools = useMemo( - () => getCompatiblePools(siloPools?.items || [], undefined, 'unicast'), + () => + getCompatiblePools(siloPools?.items || [], undefined, 'unicast') as UnicastIpPool[], [siloPools] ) - const defaultUnicastPools = unicastPools.filter((pool) => pool.isDefault) - const hasV4Default = defaultUnicastPools.some((p) => p.ipVersion === 'v4') - const hasV6Default = defaultUnicastPools.some((p) => p.ipVersion === 'v6') - - // Detect if both IPv4 and IPv6 default unicast pools exist - const hasDualDefaults = hasV4Default && hasV6Default + const { hasV4Default, hasV6Default, hasDualDefaults } = useMemo( + () => getDefaultIps(unicastPools), + [unicastPools] + ) // Use default version if available; fall back to v4 const ephemeralIpVersion: IpVersion = hasV4Default ? 'v4' : hasV6Default ? 'v6' : 'v4' @@ -854,7 +853,7 @@ const AdvancedAccordion = ({ // Filter unicast pools by compatible IP versions const compatibleUnicastPools = useMemo(() => { - return getCompatiblePools(unicastPools, compatibleVersions) + return getCompatiblePools(unicastPools, compatibleVersions) as UnicastIpPool[] }, [unicastPools, compatibleVersions]) // Track previous ability to attach ephemeral IP to detect transitions diff --git a/app/util/ip.ts b/app/util/ip.ts index 528112ebd..bc7f1b88c 100644 --- a/app/util/ip.ts +++ b/app/util/ip.ts @@ -7,6 +7,7 @@ */ import type { InstanceNetworkInterface, IpVersion } from '~/api' +import type { UnicastIpPool } from '~/components/form/fields/IpPoolSelector' // Borrowed from Valibot. I tried some from Zod and an O'Reilly regex cookbook // but they didn't match results with std::net on simple test cases @@ -126,3 +127,18 @@ export function filterFloatingIpsByVersion( return compatibleVersions.includes(ipVersion.type) }) } + +export const getDefaultIps = (pools: UnicastIpPool[]) => { + const defaultPools = pools.filter((pool) => pool.isDefault) + const v4Default = defaultPools.find((p) => p.ipVersion === 'v4') + const hasV4Default = !!v4Default + const v6Default = defaultPools.find((p) => p.ipVersion === 'v6') + const hasV6Default = !!v6Default + return { + v4Default, + v6Default, + hasV4Default, + hasV6Default, + hasDualDefaults: hasV4Default && hasV6Default, + } +} diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 4d0a42c4d..47e99a1a0 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -197,7 +197,20 @@ test('Instance networking tab — floating IPs', async ({ page }) => { await expect(page.getByRole('dialog')).toBeHidden() await expectRowVisible(externalIpTable, { name: 'rootbeer-float' }) - // Verify that the "Attach floating IP" button is disabled, since there shouldn't be any more IPs to attach + // Button should still be enabled because there's an IPv6 floating IP available + await expect(attachFloatingIpButton).toBeEnabled() + + // Attach the IPv6 floating IP as well + await attachFloatingIpButton.click() + await expectVisible(page, ['role=heading[name="Attach floating IP"]']) + await dialog.getByLabel('Floating IP').click() + await page.keyboard.press('ArrowDown') + await page.keyboard.press('Enter') + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await expectRowVisible(externalIpTable, { name: 'ipv6-float' }) + + // Now the button should be disabled, since all available floating IPs are attached await expect(attachFloatingIpButton).toBeDisabled() // Verify that the External IPs table row has an ellipsis link in it @@ -206,13 +219,11 @@ test('Instance networking tab — floating IPs', async ({ page }) => { // Detach one of the external IPs await clickRowAction(page, 'cola-float', 'Detach') await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('123.4.56.5/…')).toBeHidden() - await expect(page.getByText('external IPs123.4.56.4/123.4.56.0')).toBeVisible() // Since we detached it, we don't expect to see the row any longer await expect(externalIpTable.getByRole('cell', { name: 'cola-float' })).toBeHidden() - // And that button should be enabled again + // And that button should be enabled again (cola-float is now available to attach) await expect(attachFloatingIpButton).toBeEnabled() }) From 5c5ec5c6700c980c465931fb632c6493b333f3cd Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 29 Jan 2026 09:32:28 -0800 Subject: [PATCH 70/73] npm run fmt --- app/components/AttachEphemeralIpModal.tsx | 11 ++++++----- app/forms/floating-ip-create.tsx | 5 +---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index a6333b1ec..0961f1cf5 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -18,10 +18,7 @@ import { usePrefetchedQuery, type IpVersion, } from '~/api' -import { - IpPoolSelector, - type UnicastIpPool, -} from '~/components/form/fields/IpPoolSelector' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -63,7 +60,11 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) // Only unicast pools can be used for ephemeral IPs const compatibleUnicastPools = useMemo(() => { if (!siloPools) return [] - return getCompatiblePools(siloPools.items, compatibleVersions, 'unicast') as UnicastIpPool[] + return getCompatiblePools( + siloPools.items, + compatibleVersions, + 'unicast' + ) as UnicastIpPool[] }, [siloPools, compatibleVersions]) const hasDefaultCompatiblePool = useMemo(() => { diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 438299add..1dea71c07 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -22,10 +22,7 @@ import { import { AccordionItem } from '~/components/AccordionItem' import { DescriptionField } from '~/components/form/fields/DescriptionField' -import { - IpPoolSelector, - type UnicastIpPool, -} from '~/components/form/fields/IpPoolSelector' +import { IpPoolSelector, type UnicastIpPool } from '~/components/form/fields/IpPoolSelector' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' From cfdfc6dc1db398ca3b47ec996108c4250c65a1dc Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 29 Jan 2026 11:50:34 -0800 Subject: [PATCH 71/73] a few small tweaks before larger experiment on default NIC selection --- app/components/form/fields/NetworkInterfaceField.tsx | 9 +++++++++ app/pages/project/instances/NetworkingTab.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 7b21d54fb..4d8bd1c27 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -9,6 +9,7 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' import type { InstanceNetworkInterfaceCreate } from '@oxide/api' +import { Badge } from '@oxide/design-system/ui' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' @@ -17,6 +18,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel' import { MiniTable } from '~/ui/lib/MiniTable' import { Radio } from '~/ui/lib/Radio' import { RadioGroup } from '~/ui/lib/RadioGroup' +import { TextInputHint } from '~/ui/lib/TextInput' /** * Designed less for reuse, more to encapsulate logic that would otherwise @@ -46,6 +48,13 @@ export function NetworkInterfaceField({ return (
Network interface + + Use the project’s{' '} + + default + {' '} + VPC and Subnet, using the selected IP version(s) +
( ) const PrivateIpCell = ({ ipVersion, ip }: { ipVersion: IpVersion; ip: string }) => ( -
+
From f2a61f40fa2521a7ec6c21c819e51e82571eb267 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 29 Jan 2026 17:29:57 -0800 Subject: [PATCH 72/73] use ALL_ISH on NIC list --- app/components/AttachEphemeralIpModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 0961f1cf5..ce4c89d50 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -32,7 +32,7 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) q(api.projectIpPoolList, { query: { limit: ALL_ISH } }) ) const { data: nics } = usePrefetchedQuery( - q(api.instanceNetworkInterfaceList, { query: { project, instance } }) + q(api.instanceNetworkInterfaceList, { query: { limit: ALL_ISH, project, instance } }) ) // Determine compatible IP versions based on instance's primary network interface From ea631913e3bfd60c83f482f6777071c95d20a2e6 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Thu, 29 Jan 2026 17:39:38 -0800 Subject: [PATCH 73/73] decouple NIC defaults from external IP pool configuration --- app/components/form/fields/NetworkInterfaceField.tsx | 7 +++---- app/forms/instance-create.tsx | 11 +++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 4d8bd1c27..cd9056ff7 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -78,10 +78,9 @@ export function NetworkInterfaceField({ }} > {/* - Pre-selected default based on available IP pools, set in instance-create. - If both v4 and v6, default_dual_stack will be selected. - If only v4, default_ipv4 will be selected. - If only v6, default_ipv6 will be selected. + Pre-selected default is dual-stack when VPCs exist (set in instance-create). + This matches the API default and works with both IPv4 and IPv6 subnets. + User can manually select a specific IP version if needed. */} Default IPv4 diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a3f24aac7..fc4e28688 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -288,16 +288,11 @@ export default function CreateInstanceForm() { const hasVpcs = vpcs.items.length > 0 // Determine default network interface type: - // - If VPCs exist: default to IPv4 (or IPv6 if only v6 default pool exists, or dual-stack if both) + // - If VPCs exist: default to dual-stack (API default, works with both IPv4 and IPv6 subnets) // - If no VPCs exist: default to 'none' (user must create VPC first or use custom NICs) + // Note: Decoupled from external IP pool configuration, as NIC IP stack and external IPs are separate concerns const defaultNetworkInterfaceType: InstanceCreateInput['networkInterfaces']['type'] = - hasVpcs - ? hasV4Default && hasV6Default - ? 'default_dual_stack' - : hasV6Default && !hasV4Default - ? 'default_ipv6' - : 'default_ipv4' - : 'none' + hasVpcs ? 'default_dual_stack' : 'none' const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk'