@@ -20,13 +20,17 @@ import {
2020 useApiMutation ,
2121 useApiQueryClient ,
2222 usePrefetchedApiQuery ,
23+ type ExternalIpCreate ,
24+ type FloatingIp ,
2325 type InstanceCreate ,
2426 type InstanceDiskAttachment ,
27+ type NameOrId ,
2528} from '@oxide/api'
2629import {
2730 Images16Icon ,
2831 Instances16Icon ,
2932 Instances24Icon ,
33+ IpGlobal16Icon ,
3034 Storage16Icon ,
3135} from '@oxide/design-system/icons/react'
3236
@@ -50,19 +54,25 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField'
5054import { TextField } from '~/components/form/fields/TextField'
5155import { Form } from '~/components/form/Form'
5256import { FullPageForm } from '~/components/form/FullPageForm'
57+ import { HL } from '~/components/HL'
5358import { getProjectSelector , useForm , useProjectSelector } from '~/hooks'
5459import { addToast } from '~/stores/toast'
5560import { Badge } from '~/ui/lib/Badge'
61+ import { Button } from '~/ui/lib/Button'
5662import { Checkbox } from '~/ui/lib/Checkbox'
5763import { FormDivider } from '~/ui/lib/Divider'
5864import { EmptyMessage } from '~/ui/lib/EmptyMessage'
5965import { Listbox } from '~/ui/lib/Listbox'
6066import { Message } from '~/ui/lib/Message'
67+ import * as MiniTable from '~/ui/lib/MiniTable'
68+ import { Modal } from '~/ui/lib/Modal'
6169import { PageHeader , PageTitle } from '~/ui/lib/PageHeader'
6270import { RadioCard } from '~/ui/lib/Radio'
71+ import { Slash } from '~/ui/lib/Slash'
6372import { Tabs } from '~/ui/lib/Tabs'
6473import { TextInputHint } from '~/ui/lib/TextInput'
6574import { TipIcon } from '~/ui/lib/TipIcon'
75+ import { isTruthy } from '~/util/array'
6676import { readBlobAsBase64 } from '~/util/file'
6777import { docLinks , links } from '~/util/links'
6878import { nearest10 } from '~/util/math'
@@ -153,6 +163,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
153163 } ) ,
154164 apiQueryClient . prefetchQuery ( 'currentUserSshKeyList' , { } ) ,
155165 apiQueryClient . prefetchQuery ( 'projectIpPoolList' , { query : { limit : 1000 } } ) ,
166+ apiQueryClient . prefetchQuery ( 'floatingIpList' , { query : { project, limit : 1000 } } ) ,
156167 ] )
157168 return null
158169}
@@ -573,6 +584,28 @@ export function CreateInstanceForm() {
573584 )
574585}
575586
587+ // `ip is …` guard is necessary until we upgrade to 5.5, which handles this automatically
588+ const isFloating = (
589+ ip : ExternalIpCreate
590+ ) : ip is { type : 'floating' ; floatingIp : NameOrId } => ip . type === 'floating'
591+
592+ const FloatingIpLabel = ( { ip } : { ip : FloatingIp } ) => (
593+ < div >
594+ < div > { ip . name } </ div >
595+ < div className = "flex gap-0.5 text-tertiary selected:text-accent-secondary" >
596+ < div > { ip . ip } </ div >
597+ { ip . description && (
598+ < >
599+ < Slash />
600+ < div className = "grow overflow-hidden overflow-ellipsis whitespace-pre text-left" >
601+ { ip . description }
602+ </ div >
603+ </ >
604+ ) }
605+ </ div >
606+ </ div >
607+ )
608+
576609const AdvancedAccordion = ( {
577610 control,
578611 isSubmitting,
@@ -586,11 +619,65 @@ const AdvancedAccordion = ({
586619 // tell, inside AccordionItem, when an accordion is opened so we can scroll its
587620 // contents into view
588621 const [ openItems , setOpenItems ] = useState < string [ ] > ( [ ] )
622+ const [ floatingIpModalOpen , setFloatingIpModalOpen ] = useState ( false )
623+ const [ selectedFloatingIp , setSelectedFloatingIp ] = useState < FloatingIp | undefined > ( )
589624 const externalIps = useController ( { control, name : 'externalIps' } )
590625 const ephemeralIp = externalIps . field . value ?. find ( ( ip ) => ip . type === 'ephemeral' )
591626 const assignEphemeralIp = ! ! ephemeralIp
592627 const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp . pool : undefined
593628 const defaultPool = siloPools . find ( ( pool ) => pool . isDefault ) ?. name
629+ const attachedFloatingIps = ( externalIps . field . value || [ ] ) . filter ( isFloating )
630+
631+ const { project } = useProjectSelector ( )
632+ const { data : floatingIpList } = usePrefetchedApiQuery ( 'floatingIpList' , {
633+ query : { project, limit : 1000 } ,
634+ } )
635+
636+ // Filter out the IPs that are already attached to an instance
637+ const attachableFloatingIps = useMemo (
638+ ( ) => floatingIpList . items . filter ( ( ip ) => ! ip . instanceId ) ,
639+ [ floatingIpList ]
640+ )
641+
642+ // To find available floating IPs, we remove the ones that are already committed to this instance
643+ const availableFloatingIps = attachableFloatingIps . filter (
644+ ( ip ) => ! attachedFloatingIps . find ( ( attachedIp ) => attachedIp . floatingIp === ip . name )
645+ )
646+ const attachedFloatingIpsData = attachedFloatingIps
647+ . map ( ( ip ) => attachableFloatingIps . find ( ( fip ) => fip . name === ip . floatingIp ) )
648+ . filter ( isTruthy )
649+
650+ const closeFloatingIpModal = ( ) => {
651+ setFloatingIpModalOpen ( false )
652+ setSelectedFloatingIp ( undefined )
653+ }
654+
655+ const attachFloatingIp = ( ) => {
656+ if ( selectedFloatingIp ) {
657+ externalIps . field . onChange ( [
658+ ...( externalIps . field . value || [ ] ) ,
659+ { type : 'floating' , floatingIp : selectedFloatingIp . name } ,
660+ ] )
661+ }
662+ closeFloatingIpModal ( )
663+ }
664+
665+ const detachFloatingIp = ( name : string ) => {
666+ externalIps . field . onChange (
667+ externalIps . field . value ?. filter (
668+ ( ip ) => ! ( ip . type === 'floating' && ip . floatingIp === name )
669+ )
670+ )
671+ }
672+
673+ const isFloatingIpAttached = attachedFloatingIps . some ( ( ip ) => ip . floatingIp !== '' )
674+
675+ const selectedFloatingIpMessage = (
676+ < >
677+ This instance will be reachable at{ ' ' }
678+ { selectedFloatingIp ? < HL > { selectedFloatingIp . ip } </ HL > : 'the selected IP' }
679+ </ >
680+ )
594681
595682 return (
596683 < Accordion . Root
@@ -669,6 +756,101 @@ const AdvancedAccordion = ({
669756 />
670757 ) }
671758 </ div >
759+
760+ < div className = "flex flex-1 flex-col gap-4" >
761+ < h2 className = "text-sans-md" >
762+ Floating IPs{ ' ' }
763+ < TipIcon >
764+ Floating IPs exist independently of instances and can be attached to and
765+ detached from them as needed.
766+ </ TipIcon >
767+ </ h2 >
768+ { isFloatingIpAttached && (
769+ < MiniTable . Table >
770+ < MiniTable . Header >
771+ < MiniTable . HeadCell > Name</ MiniTable . HeadCell >
772+ < MiniTable . HeadCell > IP</ MiniTable . HeadCell >
773+ { /* For remove button */ }
774+ < MiniTable . HeadCell className = "w-12" />
775+ </ MiniTable . Header >
776+ < MiniTable . Body >
777+ { attachedFloatingIpsData . map ( ( item , index ) => (
778+ < MiniTable . Row
779+ tabIndex = { 0 }
780+ aria-rowindex = { index + 1 }
781+ aria-label = { `Name: ${ item . name } , IP: ${ item . ip } ` }
782+ key = { item . name }
783+ >
784+ < MiniTable . Cell > { item . name } </ MiniTable . Cell >
785+ < MiniTable . Cell > { item . ip } </ MiniTable . Cell >
786+ < MiniTable . RemoveCell
787+ onClick = { ( ) => detachFloatingIp ( item . name ) }
788+ label = { `remove floating IP ${ item . name } ` }
789+ />
790+ </ MiniTable . Row >
791+ ) ) }
792+ </ MiniTable . Body >
793+ </ MiniTable . Table >
794+ ) }
795+ { floatingIpList . items . length === 0 ? (
796+ < div className = "flex max-w-lg items-center justify-center rounded-lg border p-6 border-default" >
797+ < EmptyMessage
798+ icon = { < IpGlobal16Icon /> }
799+ title = "No floating IPs found"
800+ body = "Create a floating IP to attach it to this instance"
801+ />
802+ </ div >
803+ ) : (
804+ < div >
805+ < Button
806+ size = "sm"
807+ className = "shrink-0"
808+ disabled = { availableFloatingIps . length === 0 }
809+ disabledReason = "No floating IPs available"
810+ onClick = { ( ) => setFloatingIpModalOpen ( true ) }
811+ >
812+ Attach floating IP
813+ </ Button >
814+ </ div >
815+ ) }
816+
817+ < Modal
818+ isOpen = { floatingIpModalOpen }
819+ onDismiss = { closeFloatingIpModal }
820+ title = "Attach floating IP"
821+ >
822+ < Modal . Body >
823+ < Modal . Section >
824+ < Message variant = "info" content = { selectedFloatingIpMessage } />
825+ < form >
826+ < Listbox
827+ name = "floatingIp"
828+ items = { availableFloatingIps . map ( ( i ) => ( {
829+ value : i . name ,
830+ label : < FloatingIpLabel ip = { i } /> ,
831+ selectedLabel : `${ i . name } (${ i . ip } )` ,
832+ } ) ) }
833+ label = "Floating IP"
834+ onChange = { ( name ) => {
835+ setSelectedFloatingIp (
836+ availableFloatingIps . find ( ( i ) => i . name === name )
837+ )
838+ } }
839+ required
840+ placeholder = "Select floating IP"
841+ selected = { selectedFloatingIp ?. name || '' }
842+ />
843+ </ form >
844+ </ Modal . Section >
845+ </ Modal . Body >
846+ < Modal . Footer
847+ actionText = "Attach"
848+ disabled = { ! selectedFloatingIp }
849+ onAction = { attachFloatingIp }
850+ onDismiss = { closeFloatingIpModal }
851+ > </ Modal . Footer >
852+ </ Modal >
853+ </ div >
672854 </ AccordionItem >
673855 < AccordionItem
674856 value = "configuration"
0 commit comments