Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 64 additions & 29 deletions app/pages/project/instances/instance/tabs/NetworkingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
type ExternalIp,
type InstanceNetworkInterface,
} from '@oxide/api'
import { Networking24Icon } from '@oxide/design-system/icons/react'
import { IpGlobal24Icon, Networking24Icon } from '@oxide/design-system/icons/react'

import { HL } from '~/components/HL'
import { CreateNetworkInterfaceForm } from '~/forms/network-interface-create'
Expand Down Expand Up @@ -255,6 +255,16 @@ export function NetworkingTab() {
}),
]

const ephemeralIpDetach = useApiMutation('instanceEphemeralIpDetach', {
onSuccess() {
queryClient.invalidateQueries('instanceExternalIpList')
addToast({ content: 'Your ephemeral IP has been detached' })
},
onError: (err) => {
addToast({ title: 'Error', content: err.message, variant: 'error' })
},
})

const floatingIpDetach = useApiMutation('floatingIpDetach', {
onSuccess() {
queryClient.invalidateQueries('floatingIpList')
Expand All @@ -275,35 +285,50 @@ export function NetworkingTab() {
},
}

if (externalIp.kind === 'floating') {
return [
copyAction,
{
label: 'Detach',
onActivate: () =>
confirmAction({
actionType: 'danger',
doAction: () =>
floatingIpDetach.mutateAsync({
path: { floatingIp: externalIp.name },
query: { project },
}),
modalTitle: 'Detach Floating IP',
modalContent: (
<p>
Are you sure you want to detach floating IP <HL>{externalIp.name}</HL>{' '}
from <HL>{instanceName}</HL>? The instance will no longer be reachable
at <HL>{externalIp.ip}</HL>.
</p>
),
errorTitle: 'Error detaching floating IP',
}),
},
]
}
const doAction =
externalIp.kind === 'floating'
? () =>
floatingIpDetach.mutateAsync({
path: { floatingIp: externalIp.name },
query: { project },
})
: () =>
ephemeralIpDetach.mutateAsync({
path: { instance: instanceName },
query: { project },
})

return [
copyAction,
{
label: 'Detach',
onActivate: () =>
confirmAction({
actionType: 'danger',
doAction,
modalTitle: `Confirm detach ${externalIp.kind} IP`,
modalContent: (
<p>
Are you sure you want to detach{' '}
{externalIp.kind === 'ephemeral' ? (
'this ephemeral IP'
) : (
<>
floating IP <HL>{externalIp.name}</HL>
</>
)}{' '}
from <HL>{instanceName}</HL>? The instance will no longer be reachable at{' '}
<HL>{externalIp.ip}</HL>.
</p>
),
errorTitle: `Error detaching ${externalIp.kind} IP`,
}),
},
]

return [copyAction]
},
[floatingIpDetach, instanceName, project]
[ephemeralIpDetach, floatingIpDetach, instanceName, project]
)

const ipTableInstance = useReactTable({
Expand Down Expand Up @@ -338,7 +363,17 @@ export function NetworkingTab() {
/>
)}
</TableControls>
<Table aria-labelledby="attached-ips-label" table={ipTableInstance} />
{eips.items.length > 0 ? (
<Table aria-labelledby="attached-ips-label" table={ipTableInstance} />
) : (
<TableEmptyBox>
<EmptyMessage
icon={<IpGlobal24Icon />}
title="No external IPs"
body="You need to attach an external IP to be able to see it here"
/>
</TableEmptyBox>
)}

<TableControls className="mt-8">
<TableTitle id="nics-label">Network interfaces</TableTitle>
Expand Down
11 changes: 10 additions & 1 deletion mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,16 @@ export const handlers = makeHandlers({
disk.state = { state: 'detached' }
return disk
},
instanceEphemeralIpDetach({ path, query }) {
const instance = lookup.instance({ ...path, ...query })
// match API logic: find/remove first ephemeral ip attached to instance
// https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/db-queries/src/db/datastore/external_ip.rs#L782-L794
// https://github.com/oxidecomputer/omicron/blob/d52aad0/nexus/src/app/sagas/instance_ip_detach.rs#L79-L82
const ip = db.ephemeralIps.find((eip) => eip.instance_id === instance.id)
if (!ip) throw notFoundErr(`ephemeral IP for instance ${instance.name}`)
db.ephemeralIps = db.ephemeralIps.filter((eip) => eip !== ip)
return 204
},
instanceExternalIpList({ path, query }) {
const instance = lookup.instance({ ...path, ...query })

Expand Down Expand Up @@ -1281,7 +1291,6 @@ export const handlers = makeHandlers({
certificateDelete: NotImplemented,
certificateList: NotImplemented,
certificateView: NotImplemented,
instanceEphemeralIpDetach: NotImplemented,
instanceEphemeralIpAttach: NotImplemented,
instanceMigrate: NotImplemented,
instanceSerialConsoleStream: NotImplemented,
Expand Down
15 changes: 11 additions & 4 deletions test/e2e/instance-networking.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,14 @@ test('Instance networking tab — NIC table', async ({ page }) => {
test('Instance networking tab — External IPs', async ({ page }) => {
await page.goto('/projects/mock-project/instances/db1/network-interfaces')
const externalIpTable = page.getByRole('table', { name: 'External IPs' })
const attachFloatingIpButton = page.getByRole('button', { name: 'Attach floating IP' })

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

// Attach a new external IP
await page.click('role=button[name="Attach floating IP"]')
await attachFloatingIpButton.click()
await expectVisible(page, ['role=heading[name="Attach floating IP"]'])

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

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

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

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

// And that button shouldbe enabled again
await expect(page.getByRole('button', { name: 'Attach floating IP' })).toBeEnabled()
await expect(attachFloatingIpButton).toBeEnabled()

// Detach the ephemeral IP
await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeVisible()
await clickRowAction(page, 'ephemeral', 'Detach')
await page.getByRole('button', { name: 'Confirm' }).click()
await expect(externalIpTable.getByRole('cell', { name: 'ephemeral' })).toBeHidden()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add an assert before clicking the row action that this same selector is visible. The clickRowAction implicitly tests for that, but I like to use the same selector for before-and-after checks.

})