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() })