Skip to content

Commit ed4d92e

Browse files
authored
Add detach button for ephemeral ip (#2285)
* add detach button for ephemeral IP * add empty state for external IPs table
1 parent a06d852 commit ed4d92e

File tree

3 files changed

+85
-34
lines changed

3 files changed

+85
-34
lines changed

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

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
type ExternalIp,
2020
type InstanceNetworkInterface,
2121
} from '@oxide/api'
22-
import { Networking24Icon } from '@oxide/design-system/icons/react'
22+
import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/react'
2323

2424
import { HL } from '~/components/HL'
2525
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
@@ -255,6 +255,16 @@ export function NetworkingTab() {
255255
}),
256256
]
257257

258+
const ephemeralIpDetach = useApiMutation('instanceEphemeralIpDetach', {
259+
onSuccess() {
260+
queryClient.invalidateQueries('instanceExternalIpList')
261+
addToast({ content: 'Your ephemeral IP has been detached' })
262+
},
263+
onError: (err) => {
264+
addToast({ title: 'Error', content: err.message, variant: 'error' })
265+
},
266+
})
267+
258268
const floatingIpDetach = useApiMutation('floatingIpDetach', {
259269
onSuccess() {
260270
queryClient.invalidateQueries('floatingIpList')
@@ -275,35 +285,50 @@ export function NetworkingTab() {
275285
},
276286
}
277287

278-
if (externalIp.kind === 'floating') {
279-
return [
280-
copyAction,
281-
{
282-
label: 'Detach',
283-
onActivate: () =>
284-
confirmAction({
285-
actionType: 'danger',
286-
doAction: () =>
287-
floatingIpDetach.mutateAsync({
288-
path: { floatingIp: externalIp.name },
289-
query: { project },
290-
}),
291-
modalTitle: 'Detach Floating IP',
292-
modalContent: (
293-
<p>
294-
Are you sure you want to detach floating IP <HL>{externalIp.name}</HL>{' '}
295-
from <HL>{instanceName}</HL>? The instance will no longer be reachable
296-
at <HL>{externalIp.ip}</HL>.
297-
</p>
298-
),
299-
errorTitle: 'Error detaching floating IP',
300-
}),
301-
},
302-
]
303-
}
288+
const doAction =
289+
externalIp.kind === 'floating'
290+
? () =>
291+
floatingIpDetach.mutateAsync({
292+
path: { floatingIp: externalIp.name },
293+
query: { project },
294+
})
295+
: () =>
296+
ephemeralIpDetach.mutateAsync({
297+
path: { instance: instanceName },
298+
query: { project },
299+
})
300+
301+
return [
302+
copyAction,
303+
{
304+
label: 'Detach',
305+
onActivate: () =>
306+
confirmAction({
307+
actionType: 'danger',
308+
doAction,
309+
modalTitle: `Confirm detach ${externalIp.kind} IP`,
310+
modalContent: (
311+
<p>
312+
Are you sure you want to detach{' '}
313+
{externalIp.kind === 'ephemeral' ? (
314+
'this ephemeral IP'
315+
) : (
316+
<>
317+
floating IP <HL>{externalIp.name}</HL>
318+
</>
319+
)}{' '}
320+
from <HL>{instanceName}</HL>? The instance will no longer be reachable at{' '}
321+
<HL>{externalIp.ip}</HL>.
322+
</p>
323+
),
324+
errorTitle: `Error detaching ${externalIp.kind} IP`,
325+
}),
326+
},
327+
]
328+
304329
return [copyAction]
305330
},
306-
[floatingIpDetach, instanceName, project]
331+
[ephemeralIpDetach, floatingIpDetach, instanceName, project]
307332
)
308333

309334
const ipTableInstance = useReactTable({
@@ -338,7 +363,17 @@ export function NetworkingTab() {
338363
/>
339364
)}
340365
</TableControls>
341-
<Table aria-labelledby="attached-ips-label" table={ipTableInstance} />
366+
{eips.items.length > 0 ? (
367+
<Table aria-labelledby="attached-ips-label" table={ipTableInstance} />
368+
) : (
369+
<TableEmptyBox>
370+
<EmptyMessage
371+
icon={<IpGlobal24Icon />}
372+
title="No external IPs"
373+
body="You need to attach an external IP to be able to see it here"
374+
/>
375+
</TableEmptyBox>
376+
)}
342377

343378
<TableControls className="mt-8">
344379
<TableTitle id="nics-label">Network interfaces</TableTitle>

mock-api/msw/handlers.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,16 @@ export const handlers = makeHandlers({
561561
disk.state = { state: 'detached' }
562562
return disk
563563
},
564+
instanceEphemeralIpDetach({ path, query }) {
565+
const instance = lookup.instance({ ...path, ...query })
566+
// match API logic: find/remove first ephemeral ip attached to instance
567+
// https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L782-L794
568+
// https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/src/app/sagas/instance_ip_detach.rs#L79-L82
569+
const ip = db.ephemeralIps.find((eip) => eip.instance_id === instance.id)
570+
if (!ip) throw notFoundErr(`ephemeral IP for instance ${instance.name}`)
571+
db.ephemeralIps = db.ephemeralIps.filter((eip) => eip !== ip)
572+
return 204
573+
},
564574
instanceExternalIpList({ path, query }) {
565575
const instance = lookup.instance({ ...path, ...query })
566576

@@ -1281,7 +1291,6 @@ export const handlers = makeHandlers({
12811291
certificateDelete: NotImplemented,
12821292
certificateList: NotImplemented,
12831293
certificateView: NotImplemented,
1284-
instanceEphemeralIpDetach: NotImplemented,
12851294
instanceEphemeralIpAttach: NotImplemented,
12861295
instanceMigrate: NotImplemented,
12871296
instanceSerialConsoleStream: NotImplemented,

test/e2e/instance-networking.e2e.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,14 @@ test('Instance networking tab — NIC table', async ({ page }) => {
8787
test('Instance networking tab — External IPs', async ({ page }) => {
8888
await page.goto('/projects/mock-project/instances/db1/network-interfaces')
8989
const externalIpTable = page.getByRole('table', { name: 'External IPs' })
90+
const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' })
9091

9192
// See list of external IPs
9293
await expectRowVisible(externalIpTable, { ip: '123.4.56.0', Kind: 'ephemeral' })
9394
await expectRowVisible(externalIpTable, { ip: '123.4.56.5', Kind: 'floating' })
9495

9596
// Attach a new external IP
96-
await page.click('role=button[name="Attach floating IP"]')
97+
await attachFloatingIpButton.click()
9798
await expectVisible(page, ['role=heading[name="Attach floating IP"]'])
9899

99100
// Select the 'rootbeer-float' option
@@ -111,15 +112,21 @@ test('Instance networking tab — External IPs', async ({ page }) => {
111112
await expectRowVisible(externalIpTable, { name: 'rootbeer-float' })
112113

113114
// Verify that the "Attach floating IP" button is disabled, since there shouldn't be any more IPs to attach
114-
await expect(page.getByRole('button', { name: 'Attach floating IP' })).toBeDisabled()
115+
await expect(attachFloatingIpButton).toBeDisabled()
115116

116117
// Detach one of the external IPs
117118
await clickRowAction(page, 'cola-float', 'Detach')
118119
await page.getByRole('button', { name: 'Confirm' }).click()
119120

120-
// Since we detached it, we don't expect to see db1 any longer
121+
// Since we detached it, we don't expect to see the row any longer
121122
await expect(externalIpTable.getByRole('cell', { name: 'cola-float' })).toBeHidden()
122123

123124
// And that button shouldbe enabled again
124-
await expect(page.getByRole('button', { name: 'Attach floating IP' })).toBeEnabled()
125+
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()
125132
})

0 commit comments

Comments
 (0)