77 */
88import * as Accordion from '@radix-ui/react-accordion'
99import { useEffect , useMemo , useState } from 'react'
10- import { useWatch , type Control } from 'react-hook-form'
10+ import { useController , useWatch , type Control } from 'react-hook-form'
1111import { useNavigate , type LoaderFunctionArgs } from 'react-router-dom'
1212import type { SetRequired } from 'type-fest'
1313
@@ -50,12 +50,16 @@ import { Form } from '~/components/form/Form'
5050import { FullPageForm } from '~/components/form/FullPageForm'
5151import { getProjectSelector , useForm , useProjectSelector } from '~/hooks'
5252import { addToast } from '~/stores/toast'
53+ import { Badge } from '~/ui/lib/Badge'
54+ import { Checkbox } from '~/ui/lib/Checkbox'
5355import { FormDivider } from '~/ui/lib/Divider'
5456import { EmptyMessage } from '~/ui/lib/EmptyMessage'
57+ import { Listbox } from '~/ui/lib/Listbox'
5558import { Message } from '~/ui/lib/Message'
5659import { RadioCard } from '~/ui/lib/Radio'
5760import { Tabs } from '~/ui/lib/Tabs'
5861import { TextInputHint } from '~/ui/lib/TextInput'
62+ import { TipIcon } from '~/ui/lib/TipIcon'
5963import { readBlobAsBase64 } from '~/util/file'
6064import { links } from '~/util/links'
6165import { nearest10 } from '~/util/math'
@@ -130,6 +134,7 @@ const baseDefaultValues: InstanceCreateInput = {
130134 start : true ,
131135
132136 userData : null ,
137+ externalIps : [ { type : 'ephemeral' } ] ,
133138}
134139
135140const DISK_FETCH_LIMIT = 1000
@@ -144,6 +149,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
144149 query : { project, limit : DISK_FETCH_LIMIT } ,
145150 } ) ,
146151 apiQueryClient . prefetchQuery ( 'currentUserSshKeyList' , { } ) ,
152+ apiQueryClient . prefetchQuery ( 'projectIpPoolList' , { query : { limit : 1000 } } ) ,
147153 ] )
148154 return null
149155}
@@ -187,6 +193,15 @@ export function CreateInstanceForm() {
187193 const { data : sshKeys } = usePrefetchedApiQuery ( 'currentUserSshKeyList' , { } )
188194 const allKeys = useMemo ( ( ) => sshKeys . items . map ( ( key ) => key . id ) , [ sshKeys ] )
189195
196+ // projectIpPoolList fetches the pools linked to the current silo
197+ const { data : siloPools } = usePrefetchedApiQuery ( 'projectIpPoolList' , {
198+ query : { limit : 1000 } ,
199+ } )
200+ const defaultPool = useMemo (
201+ ( ) => ( siloPools ? siloPools . items . find ( ( p ) => p . isDefault ) ?. name : undefined ) ,
202+ [ siloPools ]
203+ )
204+
190205 const defaultSource =
191206 siloImages . length > 0 ? 'siloImage' : projectImages . length > 0 ? 'projectImage' : 'disk'
192207
@@ -198,6 +213,7 @@ export function CreateInstanceForm() {
198213 diskSource : disks ?. [ 0 ] ?. value || '' ,
199214 sshPublicKeys : allKeys ,
200215 bootDiskSize : nearest10 ( defaultImage ?. size / GiB ) ,
216+ externalIps : [ { type : 'ephemeral' , pool : defaultPool } ] ,
201217 }
202218
203219 const form = useForm ( { defaultValues } )
@@ -283,7 +299,7 @@ export function CreateInstanceForm() {
283299 memory : instance . memory * GiB ,
284300 ncpus : instance . ncpus ,
285301 disks : [ bootDisk , ...values . disks ] ,
286- externalIps : [ { type : 'ephemeral' } ] ,
302+ externalIps : values . externalIps ,
287303 start : values . start ,
288304 networkInterfaces : values . networkInterfaces ,
289305 sshPublicKeys : values . sshPublicKeys ,
@@ -514,7 +530,11 @@ export function CreateInstanceForm() {
514530 < SshKeysField control = { control } isSubmitting = { isSubmitting } />
515531 < FormDivider />
516532 < Form . Heading id = "advanced" > Advanced</ Form . Heading >
517- < AdvancedAccordion control = { control } isSubmitting = { isSubmitting } />
533+ < AdvancedAccordion
534+ control = { control }
535+ isSubmitting = { isSubmitting }
536+ siloPools = { siloPools . items }
537+ />
518538 < Form . Actions >
519539 < Form . Submit loading = { createInstance . isPending } > Create instance</ Form . Submit >
520540 < Form . Cancel onClick = { ( ) => navigate ( pb . instances ( { project } ) ) } />
@@ -526,14 +546,21 @@ export function CreateInstanceForm() {
526546const AdvancedAccordion = ( {
527547 control,
528548 isSubmitting,
549+ siloPools,
529550} : {
530551 control : Control < InstanceCreateInput >
531552 isSubmitting : boolean
553+ siloPools : Array < { name : string ; isDefault : boolean } >
532554} ) => {
533555 // we track this state manually for the sole reason that we need to be able to
534556 // tell, inside AccordionItem, when an accordion is opened so we can scroll its
535557 // contents into view
536558 const [ openItems , setOpenItems ] = useState < string [ ] > ( [ ] )
559+ const externalIps = useController ( { control, name : 'externalIps' } )
560+ const ephemeralIp = externalIps . field . value ?. find ( ( ip ) => ip . type === 'ephemeral' )
561+ const assignEphemeralIp = ! ! ephemeralIp
562+ const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp . pool : undefined
563+ const defaultPool = siloPools . find ( ( pool ) => pool . isDefault ) ?. name
537564
538565 return (
539566 < Accordion . Root
@@ -549,12 +576,69 @@ const AdvancedAccordion = ({
549576 >
550577 < NetworkInterfaceField control = { control } disabled = { isSubmitting } />
551578
552- < TextField
553- name = "hostname"
554- tooltipText = "Will be generated if not provided"
555- control = { control }
556- disabled = { isSubmitting }
557- />
579+ < div className = "py-2" >
580+ < TextField
581+ name = "hostname"
582+ tooltipText = "Will be generated if not provided"
583+ control = { control }
584+ disabled = { isSubmitting }
585+ />
586+ </ div >
587+
588+ < div className = "flex flex-1 flex-col gap-4" >
589+ < h2 className = "text-sans-md" >
590+ Ephemeral IP{ ' ' }
591+ < TipIcon >
592+ Ephemeral IPs are allocated when the instance is created and deallocated when
593+ it is deleted
594+ </ TipIcon >
595+ </ h2 >
596+ < div className = "flex items-start gap-2.5" >
597+ < Checkbox
598+ id = "assignEphemeralIp"
599+ checked = { assignEphemeralIp }
600+ onChange = { ( ) => {
601+ const newExternalIps = assignEphemeralIp
602+ ? externalIps . field . value ?. filter ( ( ip ) => ip . type !== 'ephemeral' )
603+ : [
604+ ...( externalIps . field . value || [ ] ) ,
605+ { type : 'ephemeral' , pool : selectedPool || defaultPool } ,
606+ ]
607+ externalIps . field . onChange ( newExternalIps )
608+ } }
609+ />
610+ < label htmlFor = "assignEphemeralIp" className = "text-sans-md text-secondary" >
611+ Allocate and attach an ephemeral IP address
612+ </ label >
613+ </ div >
614+ { assignEphemeralIp && (
615+ < Listbox
616+ name = "pools"
617+ label = "IP pool for ephemeral IP"
618+ placeholder = { defaultPool ? `${ defaultPool } (default)` : 'Select pool' }
619+ selected = { `${ siloPools . find ( ( pool ) => pool . name === selectedPool ) ?. name } ` }
620+ items = {
621+ siloPools . map ( ( pool ) => ( {
622+ label : (
623+ < div className = "flex items-center gap-2" >
624+ { pool . name }
625+ { pool . isDefault && < Badge > default</ Badge > }
626+ </ div >
627+ ) ,
628+ value : pool . name ,
629+ } ) ) || [ ]
630+ }
631+ disabled = { ! assignEphemeralIp || isSubmitting }
632+ required
633+ onChange = { ( value ) => {
634+ const newExternalIps = externalIps . field . value ?. map ( ( ip ) =>
635+ ip . type === 'ephemeral' ? { ...ip , pool : value } : ip
636+ )
637+ externalIps . field . onChange ( newExternalIps )
638+ } }
639+ />
640+ ) }
641+ </ div >
558642 </ AccordionItem >
559643 < AccordionItem
560644 value = "configuration"
0 commit comments