Skip to content

Commit e782bf2

Browse files
Add button to attach an ephemeral IP (#2290)
* add detach button for ephemeral IP * refactor * add empty state for external ips * capitalize modal title * refine copy * Update modal title to reflect that this is a confirmation step * clinging to detachment is its own form of attachment * copy tweak * Update query to rely on ip passed in as query param; need to talk through API update * Update mock api to allow for attaching ephemeral ip * Update networking tab with ephemeral ip attach button * add modal for attach ephemeral ip * Add check to ensure nic exists in order to attach ephemeral IP * Update networking tab tests * error message improvement * rows → nics * more precise locator targeting in test * refactor empty table copy * Hide 'attach ephemeral IP' button when disabled * hide 'Attach ephemeral IP' button instead of disabling it * prefetch projectIpPoolList so the pool data is available to the modal * update test to match new field label * prefer not to use locator strings in new tests * use usePrefetchedApiQuery for prefetched query! * don't need to pass instance * move attach modals to app/components --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent 34c648b commit e782bf2

File tree

6 files changed

+188
-39
lines changed

6 files changed

+188
-39
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
9+
import { useMemo } from 'react'
10+
import { useForm } from 'react-hook-form'
11+
12+
import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api'
13+
import { ListboxField } from '~/components/form/fields/ListboxField'
14+
import { useInstanceSelector } from '~/hooks'
15+
import { addToast } from '~/stores/toast'
16+
import { Badge } from '~/ui/lib/Badge'
17+
import { Modal } from '~/ui/lib/Modal'
18+
19+
export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) => {
20+
const queryClient = useApiQueryClient()
21+
const { project, instance } = useInstanceSelector()
22+
const { data: siloPools } = usePrefetchedApiQuery('projectIpPoolList', {
23+
query: { limit: 1000 },
24+
})
25+
const defaultPool = useMemo(
26+
() => siloPools?.items.find((pool) => pool.isDefault),
27+
[siloPools]
28+
)
29+
const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', {
30+
onSuccess() {
31+
queryClient.invalidateQueries('instanceExternalIpList')
32+
addToast({ content: 'Your ephemeral IP has been attached' })
33+
onDismiss()
34+
},
35+
onError: (err) => {
36+
addToast({ title: 'Error', content: err.message, variant: 'error' })
37+
},
38+
})
39+
const form = useForm({ defaultValues: { pool: defaultPool?.name } })
40+
const pool = form.watch('pool')
41+
42+
return (
43+
<Modal isOpen title="Attach ephemeral IP" onDismiss={onDismiss}>
44+
<Modal.Body>
45+
<Modal.Section>
46+
<form>
47+
<ListboxField
48+
control={form.control}
49+
name="pool"
50+
label="IP pool"
51+
placeholder={
52+
siloPools?.items && siloPools.items.length > 0
53+
? 'Select pool'
54+
: 'No pools available'
55+
}
56+
items={
57+
siloPools?.items.map((pool) => ({
58+
label: (
59+
<div className="flex items-center gap-2">
60+
{pool.name}
61+
{pool.isDefault && <Badge>default</Badge>}
62+
</div>
63+
),
64+
value: pool.name,
65+
})) || []
66+
}
67+
required
68+
/>
69+
</form>
70+
</Modal.Section>
71+
</Modal.Body>
72+
<Modal.Footer
73+
actionText="Attach"
74+
disabled={!pool}
75+
onAction={() =>
76+
instanceEphemeralIpAttach.mutate({
77+
path: { instance },
78+
query: { project },
79+
body: { pool },
80+
})
81+
}
82+
onDismiss={onDismiss}
83+
></Modal.Footer>
84+
</Modal>
85+
)
86+
}
File renamed without changes.

app/pages/project/instances/instance/tabs/NetworkingTab.tsx

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ import {
2121
} from '@oxide/api'
2222
import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/react'
2323

24+
import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal'
25+
import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal'
2426
import { HL } from '~/components/HL'
2527
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
2628
import { EditNetworkInterfaceForm } from '~/forms/network-interface-edit'
2729
import { getInstanceSelector, useInstanceSelector, useProjectSelector } from '~/hooks'
28-
import { AttachFloatingIpModal } from '~/pages/project/floating-ips/AttachFloatingIpModal'
2930
import { confirmAction } from '~/stores/confirm-action'
3031
import { confirmDelete } from '~/stores/confirm-delete'
3132
import { addToast } from '~/stores/toast'
@@ -94,6 +95,10 @@ NetworkingTab.loader = async ({ params }: LoaderFunctionArgs) => {
9495
path: { instance },
9596
query: { project },
9697
}),
98+
// This is used in AttachEphemeralIpModal
99+
apiQueryClient.prefetchQuery('projectIpPoolList', {
100+
query: { limit: 1000 },
101+
}),
97102
])
98103
return null
99104
}
@@ -131,7 +136,8 @@ export function NetworkingTab() {
131136

132137
const [createModalOpen, setCreateModalOpen] = useState(false)
133138
const [editing, setEditing] = useState<InstanceNetworkInterface | null>(null)
134-
const [attachModalOpen, setAttachModalOpen] = useState(false)
139+
const [attachEphemeralModalOpen, setAttachEphemeralModalOpen] = useState(false)
140+
const [attachFloatingModalOpen, setAttachFloatingModalOpen] = useState(false)
135141

136142
// Fetch the floating IPs to show in the "Attach floating IP" modal
137143
const { data: ips } = usePrefetchedApiQuery('floatingIpList', {
@@ -216,13 +222,13 @@ export function NetworkingTab() {
216222

217223
const columns = useColsWithActions(staticCols, makeActions)
218224

219-
const rows = usePrefetchedApiQuery('instanceNetworkInterfaceList', {
225+
const nics = usePrefetchedApiQuery('instanceNetworkInterfaceList', {
220226
query: { ...instanceSelector, limit: 1000 },
221227
}).data.items
222228

223229
const tableInstance = useReactTable({
224230
columns,
225-
data: rows || [],
231+
data: nics || [],
226232
getCoreRowModel: getCoreRowModel(),
227233
})
228234

@@ -337,9 +343,14 @@ export function NetworkingTab() {
337343
getCoreRowModel: getCoreRowModel(),
338344
})
339345

340-
const disabledReason =
341-
eips.items.length >= 32
342-
? 'IP address limit of 32 reached for this instance'
346+
// If there's already an ephemeral IP, or if there are no network interfaces,
347+
// they shouldn't be able to attach an ephemeral IP
348+
const enableEphemeralAttachButton =
349+
eips.items.filter((ip) => ip.kind === 'ephemeral').length === 0 && nics.length > 0
350+
351+
const floatingDisabledReason =
352+
eips.items.filter((ip) => ip.kind === 'floating').length >= 32
353+
? 'Floating IP address limit of 32 reached for this instance'
343354
: availableIps.length === 0
344355
? 'No available floating IPs'
345356
: null
@@ -348,18 +359,33 @@ export function NetworkingTab() {
348359
<>
349360
<TableControls>
350361
<TableTitle id="attached-ips-label">External IPs</TableTitle>
351-
<CreateButton
352-
onClick={() => setAttachModalOpen(true)}
353-
disabled={!!disabledReason}
354-
disabledReason={disabledReason}
355-
>
356-
Attach floating IP
357-
</CreateButton>
358-
{attachModalOpen && (
362+
<div className="flex gap-3">
363+
{/*
364+
We normally wouldn't hide this button and would just have a disabled state on it,
365+
but it is very rare for this button to be necessary, and it would be disabled
366+
most of the time, for most users. To reduce clutter on the screen, we're hiding it.
367+
*/}
368+
{enableEphemeralAttachButton && (
369+
<CreateButton onClick={() => setAttachEphemeralModalOpen(true)}>
370+
Attach ephemeral IP
371+
</CreateButton>
372+
)}
373+
<CreateButton
374+
onClick={() => setAttachFloatingModalOpen(true)}
375+
disabled={!!floatingDisabledReason}
376+
disabledReason={floatingDisabledReason}
377+
>
378+
Attach floating IP
379+
</CreateButton>
380+
</div>
381+
{attachEphemeralModalOpen && (
382+
<AttachEphemeralIpModal onDismiss={() => setAttachEphemeralModalOpen(false)} />
383+
)}
384+
{attachFloatingModalOpen && (
359385
<AttachFloatingIpModal
360386
floatingIps={availableIps}
361387
instance={instance}
362-
onDismiss={() => setAttachModalOpen(false)}
388+
onDismiss={() => setAttachFloatingModalOpen(false)}
363389
/>
364390
)}
365391
</TableControls>
@@ -370,7 +396,7 @@ export function NetworkingTab() {
370396
<EmptyMessage
371397
icon={<IpGlobal24Icon />}
372398
title="No external IPs"
373-
body="You need to attach an external IP to be able to see it here"
399+
body="Attach an external IP to see it here"
374400
/>
375401
</TableEmptyBox>
376402
)}
@@ -397,14 +423,14 @@ export function NetworkingTab() {
397423
/>
398424
)}
399425
</TableControls>
400-
{rows?.length && rows.length > 0 ? (
426+
{nics.length > 0 ? (
401427
<Table aria-labelledby="nics-label" table={tableInstance} />
402428
) : (
403429
<TableEmptyBox>
404430
<EmptyMessage
405431
icon={<Networking24Icon />}
406432
title="No network interfaces"
407-
body="You need to create a network interface to be able to see it here"
433+
body="Create a network interface to see it here"
408434
/>
409435
</TableEmptyBox>
410436
)}

mock-api/msw/db.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const lookupById = <T extends { id: string }>(table: T[], id: string) =>
4040
export const getIpFromPool = (poolName: string | undefined) => {
4141
const pool = lookup.ipPool({ pool: poolName })
4242
const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id)
43-
if (!ipPoolRange) throw notFoundErr
43+
if (!ipPoolRange) throw notFoundErr('IP pool range')
4444

4545
// right now, we're just using the first address in the range, but we'll
4646
// want to filter the list of available IPs for the first unused address
@@ -168,12 +168,12 @@ export const lookup = {
168168
return image
169169
},
170170
ipPool({ pool: id }: PP.IpPool): Json<Api.IpPool> {
171-
if (!id) throw notFoundErr
171+
if (!id) throw notFoundErr('Missing IP pool ID or name')
172172

173173
if (isUuid(id)) return lookupById(db.ipPools, id)
174174

175175
const pool = db.ipPools.find((p) => p.name === id)
176-
if (!pool) throw notFoundErr
176+
if (!pool) throw notFoundErr('IP pool')
177177

178178
return pool
179179
},

mock-api/msw/handlers.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,21 @@ export const handlers = makeHandlers({
561561
disk.state = { state: 'detached' }
562562
return disk
563563
},
564+
instanceEphemeralIpAttach({ path, query: projectParams, body }) {
565+
const instance = lookup.instance({ ...path, ...projectParams })
566+
const { pool } = body
567+
const firstAvailableAddress = getIpFromPool(pool)
568+
const externalIp = {
569+
ip: firstAvailableAddress,
570+
kind: 'ephemeral' as const,
571+
}
572+
db.ephemeralIps.push({
573+
instance_id: instance.id,
574+
external_ip: externalIp,
575+
})
576+
577+
return externalIp
578+
},
564579
instanceEphemeralIpDetach({ path, query }) {
565580
const instance = lookup.instance({ ...path, ...query })
566581
// match API logic: find/remove first ephemeral ip attached to instance
@@ -1291,7 +1306,6 @@ export const handlers = makeHandlers({
12911306
certificateDelete: NotImplemented,
12921307
certificateList: NotImplemented,
12931308
certificateView: NotImplemented,
1294-
instanceEphemeralIpAttach: NotImplemented,
12951309
instanceMigrate: NotImplemented,
12961310
instanceSerialConsoleStream: NotImplemented,
12971311
instanceSshPublicKeyList: NotImplemented,

test/e2e/instance-networking.e2e.ts

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,7 @@
77
*/
88
import { expect, test } from '@playwright/test'
99

10-
import {
11-
clickRowAction,
12-
expectNotVisible,
13-
expectRowVisible,
14-
expectVisible,
15-
stopInstance,
16-
} from './utils'
10+
import { clickRowAction, expectRowVisible, expectVisible, stopInstance } from './utils'
1711

1812
test('Instance networking tab — NIC table', async ({ page }) => {
1913
await page.goto('/projects/mock-project/instances/db1')
@@ -74,7 +68,7 @@ test('Instance networking tab — NIC table', async ({ page }) => {
7468
await clickRowAction(page, 'nic-2', 'Edit')
7569
await page.fill('role=textbox[name="Name"]', 'nic-3')
7670
await page.click('role=button[name="Update network interface"]')
77-
await expectNotVisible(page, ['role=cell[name="nic-2"]'])
71+
await expect(page.getByRole('cell', { name: 'nic-2' })).toBeHidden()
7872
const nic3 = page.getByRole('cell', { name: 'nic-3' })
7973
await expect(nic3).toBeVisible()
8074

@@ -84,7 +78,42 @@ test('Instance networking tab — NIC table', async ({ page }) => {
8478
await expect(nic3).toBeHidden()
8579
})
8680

87-
test('Instance networking tab — External IPs', async ({ page }) => {
81+
test('Instance networking tab — Detach / Attach Ephemeral IPs', async ({ page }) => {
82+
await page.goto('/projects/mock-project/instances/db1/network-interfaces')
83+
84+
const attachEphemeralIpButton = page.getByRole('button', { name: 'Attach ephemeral IP' })
85+
const externalIpTable = page.getByRole('table', { name: 'External IPs' })
86+
const ephemeralCell = externalIpTable.getByRole('cell', { name: 'ephemeral' })
87+
88+
// We start out with an ephemeral IP attached
89+
await expect(ephemeralCell).toBeVisible()
90+
91+
// The 'Attach ephemeral IP' button should be hidden when there is still an existing ephemeral IP
92+
await expect(attachEphemeralIpButton).toBeHidden()
93+
94+
// Detach the existing ephemeral IP
95+
await clickRowAction(page, 'ephemeral', 'Detach')
96+
await page.getByRole('button', { name: 'Confirm' }).click()
97+
await expect(ephemeralCell).toBeHidden()
98+
99+
// The 'Attach ephemeral IP' button should be visible and enabled now that the existing ephemeral IP has been detached
100+
await expect(attachEphemeralIpButton).toBeEnabled()
101+
102+
// Attach a new ephemeral IP
103+
await attachEphemeralIpButton.click()
104+
const modal = page.getByRole('dialog', { name: 'Attach ephemeral IP' })
105+
await expect(modal).toBeVisible()
106+
await page.getByRole('button', { name: 'IP pool' }).click()
107+
await page.getByRole('option', { name: 'ip-pool-2' }).click()
108+
await page.getByRole('button', { name: 'Attach', exact: true }).click()
109+
await expect(modal).toBeHidden()
110+
await expect(ephemeralCell).toBeVisible()
111+
112+
// The 'Attach ephemeral IP' button should be hidden after attaching an ephemeral IP
113+
await expect(attachEphemeralIpButton).toBeHidden()
114+
})
115+
116+
test('Instance networking tab — floating IPs', async ({ page }) => {
88117
await page.goto('/projects/mock-project/instances/db1/network-interfaces')
89118
const externalIpTable = page.getByRole('table', { name: 'External IPs' })
90119
const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' })
@@ -121,12 +150,6 @@ test('Instance networking tab — External IPs', async ({ page }) => {
121150
// Since we detached it, we don't expect to see the row any longer
122151
await expect(externalIpTable.getByRole('cell', { name: 'cola-float' })).toBeHidden()
123152

124-
// And that button shouldbe enabled again
153+
// And that button should be enabled again
125154
await expect(attachFloatingIpButton).toBeEnabled()
126-
127-
// Detach the ephemeral IP
128-
await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible()
129-
await clickRowAction(page, 'ephemeral', 'Detach')
130-
await page.getByRole('button', { name: 'Confirm' }).click()
131-
await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeHidden()
132155
})

0 commit comments

Comments
 (0)