diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
index 1315fcd6f7..5982230e21 100644
--- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
+++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx
@@ -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'
@@ -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')
@@ -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: (
-
- Are you sure you want to detach floating IP {externalIp.name}{' '}
- from {instanceName}? The instance will no longer be reachable
- at {externalIp.ip}.
-
- ),
- 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: (
+
+ Are you sure you want to detach{' '}
+ {externalIp.kind === 'ephemeral' ? (
+ 'this ephemeral IP'
+ ) : (
+ <>
+ floating IP {externalIp.name}
+ >
+ )}{' '}
+ from {instanceName}? The instance will no longer be reachable at{' '}
+ {externalIp.ip}.
+
+ ),
+ errorTitle: `Error detaching ${externalIp.kind} IP`,
+ }),
+ },
+ ]
+
return [copyAction]
},
- [floatingIpDetach, instanceName, project]
+ [ephemeralIpDetach, floatingIpDetach, instanceName, project]
)
const ipTableInstance = useReactTable({
@@ -338,7 +363,17 @@ export function NetworkingTab() {
/>
)}
-
+ {eips.items.length > 0 ? (
+
+ ) : (
+
+ }
+ title="No external IPs"
+ body="You need to attach an external IP to be able to see it here"
+ />
+
+ )}
Network interfaces
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts
index 28736e37c6..ef04401c2a 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -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 })
@@ -1281,7 +1291,6 @@ export const handlers = makeHandlers({
certificateDelete: NotImplemented,
certificateList: NotImplemented,
certificateView: NotImplemented,
- instanceEphemeralIpDetach: NotImplemented,
instanceEphemeralIpAttach: NotImplemented,
instanceMigrate: NotImplemented,
instanceSerialConsoleStream: NotImplemented,
diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts
index 11e9695ef0..6584b1fbef 100644
--- a/test/e2e/instance-networking.e2e.ts
+++ b/test/e2e/instance-networking.e2e.ts
@@ -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
@@ -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()
})