Skip to content

Commit c9f2bba

Browse files
Add floating ips to instance create (#2252)
* Add step to attach floating IPs to instance creation form * Slight refactoring of const names * Smoother handling of unchecking Floating IP box * Add noItemsPlaceholder to Listbox * add type guard to clean up code * Remove checkbox; button-only to open modal * only show message when IPs are available to attach * Simplify floating IP label, sans-pool * revert removal of api call * Add header to new file * Merge main; disable modal trigger button if no floating IPs available * Add empty state for when no floatingIPs exist * Update app/forms/instance-create.tsx Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> * Refactor deeply-nested JSX * Extract FloatingIpLabel, update TipIcon copy * Extract Listbox labels * Revert "Extract Listbox labels" This reverts commit 59b27d1. * Refactor to use useState instead of weird form entry for state management * small refactor on onAction * another small refactor * refactor to use FloatingIp in useState, rather than a string * simplify and future-proof logic Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> * Add test for attaching a floating IP to instance * include assertion regarding IP address in modal message * Add test for empty floating IPs state * Update mock serviceworker to attach IPs to instance; update tests * Move ipFromPool to util function * refactor * fix the button! * use RemoveCell in floating IPs MiniTable * Add test to remove row from form --------- Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 9b7ea53 commit c9f2bba

File tree

9 files changed

+313
-11
lines changed

9 files changed

+313
-11
lines changed

app/components/form/fields/ImageSelectField.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { Image } from '@oxide/api'
1111

1212
import type { InstanceCreateInput } from '~/forms/instance-create'
1313
import type { ListboxItem } from '~/ui/lib/Listbox'
14+
import { Slash } from '~/ui/lib/Slash'
1415
import { nearest10 } from '~/util/math'
1516
import { bytesToGiB, GiB } from '~/util/units'
1617

@@ -50,10 +51,6 @@ export function BootDiskImageSelectField({
5051
)
5152
}
5253

53-
const Slash = () => (
54-
<span className="mx-1 text-quinary selected:text-accent-disabled">/</span>
55-
)
56-
5754
export function toListboxItem(i: Image, includeProjectSiloIndicator = false): ListboxItem {
5855
const { name, os, projectId, size, version } = i
5956
const formattedSize = `${bytesToGiB(size, 1)} GiB`

app/forms/disk-create.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { FormDivider } from '~/ui/lib/Divider'
3434
import { FieldLabel } from '~/ui/lib/FieldLabel'
3535
import { Radio } from '~/ui/lib/Radio'
3636
import { RadioGroup } from '~/ui/lib/RadioGroup'
37+
import { Slash } from '~/ui/lib/Slash'
3738
import { toLocaleDateString } from '~/util/date'
3839
import { bytesToGiB, GiB } from '~/util/units'
3940

@@ -259,9 +260,8 @@ const SnapshotSelectField = ({ control }: { control: Control<DiskCreate> }) => {
259260
<div>{i.name}</div>
260261
<div className="text-tertiary selected:text-accent-secondary">
261262
Created on {toLocaleDateString(i.timeCreated)}
262-
<DiskNameFromId disk={i.diskId} />{' '}
263-
<span className="mx-1 text-quinary selected:text-accent-disabled">/</span>{' '}
264-
{formattedSize.value} {formattedSize.unit}
263+
<DiskNameFromId disk={i.diskId} /> <Slash /> {formattedSize.value}{' '}
264+
{formattedSize.unit}
265265
</div>
266266
</>
267267
),

app/forms/instance-create.tsx

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
2629
import {
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'
5054
import { TextField } from '~/components/form/fields/TextField'
5155
import { Form } from '~/components/form/Form'
5256
import { FullPageForm } from '~/components/form/FullPageForm'
57+
import { HL } from '~/components/HL'
5358
import { getProjectSelector, useForm, useProjectSelector } from '~/hooks'
5459
import { addToast } from '~/stores/toast'
5560
import { Badge } from '~/ui/lib/Badge'
61+
import { Button } from '~/ui/lib/Button'
5662
import { Checkbox } from '~/ui/lib/Checkbox'
5763
import { FormDivider } from '~/ui/lib/Divider'
5864
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
5965
import { Listbox } from '~/ui/lib/Listbox'
6066
import { Message } from '~/ui/lib/Message'
67+
import * as MiniTable from '~/ui/lib/MiniTable'
68+
import { Modal } from '~/ui/lib/Modal'
6169
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
6270
import { RadioCard } from '~/ui/lib/Radio'
71+
import { Slash } from '~/ui/lib/Slash'
6372
import { Tabs } from '~/ui/lib/Tabs'
6473
import { TextInputHint } from '~/ui/lib/TextInput'
6574
import { TipIcon } from '~/ui/lib/TipIcon'
75+
import { isTruthy } from '~/util/array'
6676
import { readBlobAsBase64 } from '~/util/file'
6777
import { docLinks, links } from '~/util/links'
6878
import { 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+
576609
const 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"

app/pages/project/floating-ips/AttachFloatingIpModal.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
1313
import { addToast } from '~/stores/toast'
1414
import { Message } from '~/ui/lib/Message'
1515
import { Modal } from '~/ui/lib/Modal'
16+
import { Slash } from '~/ui/lib/Slash'
1617

1718
function FloatingIpLabel({ fip }: { fip: FloatingIp }) {
1819
return (
@@ -22,7 +23,7 @@ function FloatingIpLabel({ fip }: { fip: FloatingIp }) {
2223
<div>{fip.ip}</div>
2324
{fip.description && (
2425
<>
25-
<span className="mx-1 text-quinary selected:text-accent-disabled">/</span>
26+
<Slash />
2627
<div className="grow overflow-hidden overflow-ellipsis whitespace-pre text-left">
2728
{fip.description}
2829
</div>

app/ui/lib/Listbox.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export interface ListboxProps<Value extends string = string> {
3232
onChange: (value: Value) => void
3333
items: ListboxItem<Value>[]
3434
placeholder?: string
35+
noItemsPlaceholder?: string
3536
className?: string
3637
disabled?: boolean
3738
hasError?: boolean
@@ -48,6 +49,7 @@ export const Listbox = <Value extends string = string>({
4849
selected,
4950
items,
5051
placeholder = 'Select an option',
52+
noItemsPlaceholder = 'No items',
5153
className,
5254
onChange,
5355
hasError = false,
@@ -107,7 +109,7 @@ export const Listbox = <Value extends string = string>({
107109
selectedItem.selectedLabel || selectedItem.label
108110
) : (
109111
<span className="text-quaternary">
110-
{noItems ? 'No items' : placeholder}
112+
{noItems ? noItemsPlaceholder : placeholder}
111113
</span>
112114
)}
113115
</div>

app/ui/lib/Slash.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
export const Slash = () => (
9+
<span className="mx-1 text-quinary selected:text-accent-disabled">/</span>
10+
)

mock-api/msw/db.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ export const lookupById = <T extends { id: string }>(table: T[], id: string) =>
3737
return item
3838
}
3939

40+
export const getIpFromPool = (poolName: string | undefined) => {
41+
const pool = lookup.ipPool({ pool: poolName })
42+
const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id)
43+
if (!ipPoolRange) throw notFoundErr
44+
45+
// right now, we're just using the first address in the range, but we'll
46+
// want to filter the list of available IPs for the first unused address
47+
// also: think through how calling code might want to handle various issues
48+
// and what appropriate error codes would be: no ranges? pool is exhausted? etc.
49+
return ipPoolRange.range.first
50+
}
51+
4052
export const lookup = {
4153
project({ project: id }: PP.Project): Json<Api.Project> {
4254
if (!id) throw notFoundErr

0 commit comments

Comments
 (0)