diff --git a/app/api/util.ts b/app/api/util.ts index f3091f865..674761a8f 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -22,6 +22,7 @@ import type { SiloIpPool, SiloUtilization, Sled, + Vpc, VpcFirewallRule, VpcFirewallRuleUpdate, } from './__generated__/Api' @@ -46,6 +47,18 @@ export const MIN_DISK_SIZE_GiB = 1 */ export const MAX_DISK_SIZE_GiB = 1023 +/** + * The `default_*` network interface attachment types resolve a VPC and VPC + * subnet both named literally 'default', so they fail with a 404 if that VPC + * doesn't exist, even when the project has other VPCs. + * + * https://github.com/oxidecomputer/omicron/blob/7a15082/nexus/src/app/sagas/instance_create.rs#L739-L773 + */ +export const DEFAULT_VPC_NAME = 'default' + +export const hasDefaultVpc = (vpcs: Vpc[]) => + vpcs.some((vpc) => vpc.name === DEFAULT_VPC_NAME) + type PortRange = [number, number] /** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */ diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index a9fd0f3ef..9d5a7cc66 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -8,8 +8,14 @@ import { useState } from 'react' import { useController, type Control } from 'react-hook-form' -import type { InstanceNetworkInterfaceCreate } from '@oxide/api' +import { + DEFAULT_VPC_NAME, + hasDefaultVpc, + type InstanceNetworkInterfaceCreate, + type Vpc, +} from '@oxide/api' +import { HL } from '~/components/HL' import type { InstanceCreateInput } from '~/forms/instance-create' import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create' import { Button } from '~/ui/lib/Button' @@ -17,6 +23,7 @@ import { FieldLabel } from '~/ui/lib/FieldLabel' import { Listbox } from '~/ui/lib/Listbox' import { MiniTable } from '~/ui/lib/MiniTable' import { Radio } from '~/ui/lib/Radio' +import { TipIcon } from '~/ui/lib/TipIcon' const networkInterfaceTableColumns = [ { header: 'Name', cell: (item: InstanceNetworkInterfaceCreate) => item.name }, @@ -31,13 +38,14 @@ const networkInterfaceTableColumns = [ export function NetworkInterfaceField({ control, disabled, - hasVpcs, + vpcs, }: { control: Control disabled: boolean - hasVpcs: boolean + vpcs: Vpc[] }) { const [showForm, setShowForm] = useState(false) + const defaultVpcExists = hasDefaultVpc(vpcs) /** * Used to preserve previous user choices in case they accidentally @@ -79,15 +87,26 @@ export function NetworkInterfaceField({ aria-labelledby="network-interface-type-label" >
- handleModeChange(e.target.value)} - > - Default - + + handleModeChange(e.target.value)} + > + Default + + { + // the no VPCs case is covered by a separate yellow banner message + // saying you can't have any network interfaces + vpcs.length > 0 && !defaultVpcExists && ( + + Default networking requires a VPC named {DEFAULT_VPC_NAME} + + ) + } + {currentMode === 'default' && (
handleModeChange(e.target.value)} > diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index ce57bbea6..00523a1d1 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -16,6 +16,7 @@ import { api, diskCan, genName, + hasDefaultVpc, INSTANCE_MAX_CPU, INSTANCE_MAX_RAM_GiB, isUnicastPool, @@ -34,6 +35,7 @@ import { type IpVersion, type NameOrId, type UnicastIpPool, + type Vpc, } from '@oxide/api' import { Images16Icon, @@ -410,19 +412,16 @@ export default function CreateInstanceForm() { [siloPools] ) - // 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 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) + // - If a default VPC exists: default to dual-stack (API default, works with both IPv4 and IPv6 subnets) + // - Otherwise: default to 'none' (user must create a 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: InstanceNetworkInterfaceAttachment['type'] = hasVpcs - ? 'default_dual_stack' - : 'none' + const defaultNetworkInterfaceType: InstanceNetworkInterfaceAttachment['type'] = + hasDefaultVpc(vpcs.items) ? 'default_dual_stack' : 'none' const defaultSource = siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk' @@ -841,7 +840,7 @@ export default function CreateInstanceForm() { control={control} isSubmitting={isSubmitting} unicastPools={unicastPools} - hasVpcs={hasVpcs} + vpcs={vpcs.items} /> Advanced @@ -878,12 +877,12 @@ const NetworkingSection = ({ control, isSubmitting, unicastPools, - hasVpcs, + vpcs, }: { control: Control isSubmitting: boolean unicastPools: UnicastIpPool[] - hasVpcs: boolean + vpcs: Vpc[] }) => { const networkInterfaces = useWatch({ control, name: 'networkInterfaces' }) const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false) @@ -953,21 +952,20 @@ const NetworkingSection = ({ ) + const vpcMessage = + vpcs.length === 0 ? ( + <> + A VPC is required to add network interfaces.{' '} + Create a VPC to enable networking. + + ) : null + return ( <> - {!hasVpcs && ( - - A VPC is required to add network interfaces.{' '} - Create a VPC to enable networking. - - } - /> + {vpcMessage && ( + )} - +

diff --git a/app/ui/lib/Radio.tsx b/app/ui/lib/Radio.tsx index 2e74543bd..7d5fdf843 100644 --- a/app/ui/lib/Radio.tsx +++ b/app/ui/lib/Radio.tsx @@ -27,7 +27,7 @@ const fieldStyles = ` ` export const Radio = ({ children, className, ...inputProps }: RadioProps) => ( -