diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index 29758362a1..b1dc84aa42 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -16,7 +16,7 @@ import { SubnetListbox } from 'app/components/form/fields/SubnetListbox' import type { CreateSideModalFormProps } from 'app/forms' import { useParams } from 'app/hooks' -const values = { +const values: NetworkInterfaceCreate = { name: '', description: '', ip: '', @@ -31,6 +31,7 @@ export default function CreateNetworkInterfaceSideModalForm({ onSubmit, onSuccess, onError, + onDismiss, ...props }: CreateSideModalFormProps) { const queryClient = useApiQueryClient() @@ -45,6 +46,7 @@ export default function CreateNetworkInterfaceSideModalForm({ ...others, }) onSuccess?.(data) + onDismiss() }, onError, }) @@ -56,6 +58,7 @@ export default function CreateNetworkInterfaceSideModalForm({ id={id} title={title} initialValues={initialValues} + onDismiss={onDismiss} onSubmit={ onSubmit || ((body) => { diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx new file mode 100644 index 0000000000..9c13b0c686 --- /dev/null +++ b/app/forms/network-interface-edit.tsx @@ -0,0 +1,74 @@ +import invariant from 'tiny-invariant' + +import type { NetworkInterface, NetworkInterfaceUpdate } from '@oxide/api' +import { useApiMutation, useApiQueryClient } from '@oxide/api' + +import { DescriptionField, Form, NameField, SideModalForm } from 'app/components/form' +import type { EditSideModalFormProps } from 'app/forms' +import { useParams } from 'app/hooks' + +export default function EditNetworkInterfaceSideModalForm({ + id = 'edit-network-interface-form', + title = 'Edit network interface', + onSubmit, + onSuccess, + onError, + onDismiss, + initialValues, + ...props +}: EditSideModalFormProps) { + const queryClient = useApiQueryClient() + const pathParams = useParams('orgName', 'projectName') + + const editNetworkInterface = useApiMutation('instanceNetworkInterfacesPutInterface', { + onSuccess(data) { + const { instanceName, ...others } = pathParams + invariant(instanceName, 'instanceName is required when posting a network interface') + queryClient.invalidateQueries('instanceNetworkInterfacesGet', { + instanceName, + ...others, + }) + onSuccess?.(data) + onDismiss() + }, + onError, + }) + + return ( + { + const { instanceName, ...others } = pathParams + const interfaceName = initialValues.name + invariant( + interfaceName, + 'interfaceName is required when updating a network interface' + ) + invariant( + instanceName, + 'instanceName is required when posting a network interface' + ) + + editNetworkInterface.mutate({ + instanceName, + interfaceName, + ...others, + body, + }) + }) + } + submitDisabled={editNetworkInterface.isLoading} + error={editNetworkInterface.error?.error as Error | undefined} + {...props} + > + + + Save changes + + ) +} diff --git a/app/pages/__tests__/click-everything.e2e.ts b/app/pages/__tests__/click-everything.e2e.ts index becc865618..eea7f46508 100644 --- a/app/pages/__tests__/click-everything.e2e.ts +++ b/app/pages/__tests__/click-everything.e2e.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test' -import { expectNotVisible, expectVisible } from 'app/util/e2e' +import { expectNotVisible, expectRowVisible, expectVisible } from 'app/util/e2e' test("Click through everything and make it's all there", async ({ page }) => { await page.goto('/') @@ -107,7 +107,13 @@ test("Click through everything and make it's all there", async ({ page }) => { // Instance networking tab await page.click('role=tab[name="Networking"]') - await expectVisible(page, ['role=cell[name="my-nic"]']) + await expectRowVisible(page, 'my-nic', [ + '', + 'my-nic', + 'a network interface', + '172.30.0.10', + 'primary', + ]) await page.click('role=button[name="Add network interface"]') // Add network interface @@ -129,15 +135,41 @@ test("Click through everything and make it's all there", async ({ page }) => { await page.click('role=button[name="Add network interface"]') await expectVisible(page, ['role=cell[name="nic-2"]']) - // Delete just-added network interface + // Make this interface primary + await page + .locator('role=row', { hasText: 'nic-2' }) + .locator('role=button[name="Row actions"]') + .click() + await page.click('role=menuitem[name="Make primary"]') + await expectRowVisible(page, 'my-nic', [ + '', + 'my-nic', + 'a network interface', + '172.30.0.10', + '', + ]) + await expectRowVisible(page, 'nic-2', ['', 'nic-2', null, null, 'primary']) + + // Make an edit to the network interface await page .locator('role=row', { hasText: 'nic-2' }) .locator('role=button[name="Row actions"]') .click() + await page.click('role=menuitem[name="Edit"]') + await page.fill('role=textbox[name="Name"]', 'nic-3') + await page.click('role=button[name="Save changes"]') + await expectNotVisible(page, ['role=cell[name="nic-2"]']) + await expectVisible(page, ['role=cell[name="nic-3"]']) + + // Delete just-added network interface + await page + .locator('role=row', { hasText: 'nic-3' }) + .locator('role=button[name="Row actions"]') + .click() await page.click('role=menuitem[name="Delete"]') // Close toast, it holds up the test for some reason await page.click('role=button[name="Dismiss notification"]') - await expectNotVisible(page, ['role=cell[name="nic-2"]']) + await expectNotVisible(page, ['role=cell[name="nic-3"]']) // Snapshots page await page.click('role=link[name*="Snapshots"]') diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index d756c192d6..5f49251ab9 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -1,19 +1,21 @@ import { useState } from 'react' -import type { NetworkInterface } from '@oxide/api' +import type { NetworkInterface, NetworkInterfaceUpdate } from '@oxide/api' import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { useQueryTable } from '@oxide/table' import { + Badge, Button, Delete16Icon, EmptyMessage, - Info16Icon, Networking24Icon, - Tooltip, + OpenLink12Icon, + Success12Icon, } from '@oxide/ui' import CreateNetworkInterfaceSideModalForm from 'app/forms/network-interface-create' +import EditNetworkInterfaceSideModalForm from 'app/forms/network-interface-edit' import { useParams, useToast } from 'app/hooks' export function NetworkingTab() { @@ -22,6 +24,7 @@ export function NetworkingTab() { const addToast = useToast() const [createModalOpen, setCreateModalOpen] = useState(false) + const [editing, setEditing] = useState(null) const getQuery = ['instanceNetworkInterfacesGet', instanceParams] as const @@ -36,12 +39,42 @@ export function NetworkingTab() { }, }) + const editNic = useApiMutation('instanceNetworkInterfacesPutInterface', { + onSuccess() { + queryClient.invalidateQueries(...getQuery) + }, + }) + + const instanceStopped = + useApiQuery('projectInstancesGetInstance', instanceParams).data?.runState === 'stopped' + const makeActions = (nic: NetworkInterface): MenuAction[] => [ + { + label: 'Make primary', + onActivate() { + editNic.mutate({ + ...instanceParams, + interfaceName: nic.name, + body: { ...nic, makePrimary: true }, + }) + }, + disabled: nic.primary || !instanceStopped, + }, + { + label: 'Edit', + onActivate() { + // TODO: Revisit after https://github.com/oxidecomputer/omicron/pull/1288 is merged + setEditing({ ...nic, makePrimary: nic.primary }) + }, + disabled: !instanceStopped, + }, { label: 'Delete', + className: 'destructive', onActivate: () => { deleteNic.mutate({ ...instanceParams, interfaceName: nic.name }) }, + disabled: !instanceStopped, }, ] @@ -55,48 +88,60 @@ export function NetworkingTab() { /> ) - const instanceStopped = - useApiQuery('projectInstancesGetInstance', instanceParams).data?.runState === 'stopped' - const { Table, Column } = useQueryTable(...getQuery) return ( <> -
- { - // TODO: update icon color - // TODO: the tooltip pops up on the right edge of the icon instead of - // the middle, wtf. not worth fixing because we're going to redo - // Tooltip anyway - // TODO: would be cool to also show the tooltip on button hover when it's disabled - !instanceStopped && ( - - - - ) - } - - setCreateModalOpen(false)} - onSuccess={() => setCreateModalOpen(false)} - /> -
+

+ Network Interfaces +

{/* TODO: mark v4 or v6 explicitly? */} + + value && ( + <> + + primary + + ) + } + />
+
+
+ +
+ {!instanceStopped && ( + + A network interface cannot be created or edited without{' '} + + stopping the instance + + + + )} +
+ + setCreateModalOpen(false)} + /> + setEditing(null)} + /> ) } diff --git a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx index 3503979bdf..cc87019b65 100644 --- a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx @@ -41,13 +41,11 @@ export const VpcRoutersTab = () => { setCreateModalOpen(false)} onDismiss={() => setCreateModalOpen(false)} /> setEditing(null)} onDismiss={() => setEditing(null)} /> diff --git a/app/util/e2e.ts b/app/util/e2e.ts index a01721d08c..c411fe6361 100644 --- a/app/util/e2e.ts +++ b/app/util/e2e.ts @@ -32,11 +32,15 @@ export async function expectNotVisible(page: Page, selectors: string[]) { export async function expectRowVisible( page: Page, rowSelectorText: string, - cellTexts: string[] + cellTexts: Array ) { const row = page.locator(`tr:has-text("${rowSelectorText}")`) await expect(row).toBeVisible() for (let i = 0; i < cellTexts.length; i++) { - await expect(row.locator(`role=cell >> nth=${i}`)).toHaveText(cellTexts[i]) + const text = cellTexts[i] + if (text === null) { + continue + } + await expect(row.locator(`role=cell >> nth=${i}`)).toHaveText(text) } } diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 978769d113..f4f60842a1 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -528,6 +528,40 @@ export const handlers = [ } ), + rest.put< + Json, + NetworkInterfaceParams, + Json | PostErr + >( + '/api/organizations/:orgName/projects/:projectName/instances/:instanceName/network-interfaces/:interfaceName', + (req, res, ctx) => { + const [nic, err] = lookupNetworkInterface(req.params) + if (err) return res(err) + + if (req.body.name) { + nic.name = req.body.name + } + if (typeof req.body.description === 'string') { + nic.description = req.body.description + } + if ( + typeof req.body.make_primary === 'boolean' && + req.body.make_primary !== nic.primary + ) { + if (nic.primary) { + return res(badRequest('Cannot remove the primary interface')) + } + db.networkInterfaces + .filter((n) => n.instance_id === nic.instance_id) + .forEach((n) => { + n.primary = false + }) + nic.primary = !!req.body.make_primary + } + return res(ctx.status(204)) + } + ), + rest.delete( '/api/organizations/:orgName/projects/:projectName/instances/:instanceName/network-interfaces/:interfaceName', (req, res, ctx) => {