From 88b5f8f361a430b6624337c92673ea76d1c1e2f7 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 28 Jun 2022 00:03:49 -0400 Subject: [PATCH 1/5] First pass at nic update --- app/forms/network-interface-create.tsx | 5 +- app/forms/network-interface-edit.tsx | 84 +++++++++++++++++++ .../instances/instance/tabs/NetworkingTab.tsx | 22 ++++- .../networking/VpcPage/tabs/VpcRoutersTab.tsx | 2 - libs/api-mocks/msw/handlers.ts | 34 ++++++++ 5 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 app/forms/network-interface-edit.tsx 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..f37c5658e2 --- /dev/null +++ b/app/forms/network-interface-edit.tsx @@ -0,0 +1,84 @@ +import invariant from 'tiny-invariant' + +import type { NetworkInterface, NetworkInterfaceUpdate } from '@oxide/api' +import { useApiMutation, useApiQueryClient } from '@oxide/api' +import { CheckboxField } from '@oxide/ui' + +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} + > + + + { + + Primary + + } + Save Changes + + ) +} diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 0535a3d4d2..b06763aff3 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -1,6 +1,6 @@ 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' @@ -10,10 +10,12 @@ import { EmptyMessage, Info16Icon, Networking24Icon, + Success12Icon, Tooltip, } 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 @@ -37,6 +40,13 @@ export function NetworkingTab() { }) const makeActions = (nic: NetworkInterface): MenuAction[] => [ + { + label: 'Edit', + onActivate() { + // TODO: Revisit after https://github.com/oxidecomputer/omicron/pull/1288 is merged + setEditing({ ...nic, makePrimary: nic.primary }) + }, + }, { label: 'Delete', onActivate: () => { @@ -88,7 +98,11 @@ export function NetworkingTab() { setCreateModalOpen(false)} - onSuccess={() => setCreateModalOpen(false)} + /> + setEditing(null)} /> @@ -96,6 +110,10 @@ export function NetworkingTab() { {/* TODO: mark v4 or v6 explicitly? */} + value && } + />
) diff --git a/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcRoutersTab.tsx index 3a927c2aec..d1455231d1 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/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) => { From ba466d8f70ab66fee9cdb14e9f99012efb4242a6 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 28 Jun 2022 15:38:00 -0400 Subject: [PATCH 2/5] Add promoting a NIC to primary through the menu --- app/forms/network-interface-edit.tsx | 10 ---------- .../instances/instance/tabs/NetworkingTab.tsx | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index f37c5658e2..6fd884e81a 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -2,7 +2,6 @@ import invariant from 'tiny-invariant' import type { NetworkInterface, NetworkInterfaceUpdate } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' -import { CheckboxField } from '@oxide/ui' import { DescriptionField, Form, NameField, SideModalForm } from 'app/components/form' import type { EditSideModalFormProps } from 'app/forms' @@ -69,15 +68,6 @@ export default function EditNetworkInterfaceSideModalForm({ > - { - - Primary - - } Save Changes ) diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index b06763aff3..2b08e02c5e 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -39,7 +39,24 @@ export function NetworkingTab() { }, }) + const editNic = useApiMutation('instanceNetworkInterfacesPutInterface', { + onSuccess() { + queryClient.invalidateQueries(...getQuery) + }, + }) + const makeActions = (nic: NetworkInterface): MenuAction[] => [ + { + label: 'Make primary', + onActivate() { + editNic.mutate({ + ...instanceParams, + interfaceName: nic.name, + body: { ...nic, makePrimary: true }, + }) + }, + disabled: nic.primary, + }, { label: 'Edit', onActivate() { From 9d262c913b996d265c66a5922d622f59ff27e1ce Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 28 Jun 2022 15:42:35 -0400 Subject: [PATCH 3/5] Add primary label --- .../project/instances/instance/tabs/NetworkingTab.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 2b08e02c5e..6ad84b12c4 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -5,6 +5,7 @@ import { useApiMutation, useApiQuery, useApiQueryClient } from '@oxide/api' import type { MenuAction } from '@oxide/table' import { useQueryTable } from '@oxide/table' import { + Badge, Button, Delete16Icon, EmptyMessage, @@ -129,7 +130,14 @@ export function NetworkingTab() { value && } + cell={({ value }) => + value && ( + <> + + primary + + ) + } /> From 0d74f4afdbcc53702dfa4a8bd647e8a2148536db Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 28 Jun 2022 16:27:34 -0400 Subject: [PATCH 4/5] Add tests for NICs --- app/forms/network-interface-edit.tsx | 2 +- app/pages/__tests__/click-everything.e2e.ts | 40 ++++++++++++++++++--- app/util/e2e.ts | 8 +++-- 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 6fd884e81a..9c13b0c686 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -68,7 +68,7 @@ export default function EditNetworkInterfaceSideModalForm({ > - Save Changes + 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/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) } } From 63e24c4f1891296298171ef445931746993a4d63 Mon Sep 17 00:00:00 2001 From: Justin Bennett Date: Tue, 28 Jun 2022 16:40:14 -0400 Subject: [PATCH 5/5] Update network interface UI to match storage UI --- .../instances/instance/tabs/NetworkingTab.tsx | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 244d203cf3..5f49251ab9 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -9,10 +9,9 @@ import { Button, Delete16Icon, EmptyMessage, - Info16Icon, Networking24Icon, + OpenLink12Icon, Success12Icon, - Tooltip, } from '@oxide/ui' import CreateNetworkInterfaceSideModalForm from 'app/forms/network-interface-create' @@ -46,6 +45,9 @@ export function NetworkingTab() { }, }) + const instanceStopped = + useApiQuery('projectInstancesGetInstance', instanceParams).data?.runState === 'stopped' + const makeActions = (nic: NetworkInterface): MenuAction[] => [ { label: 'Make primary', @@ -56,7 +58,7 @@ export function NetworkingTab() { body: { ...nic, makePrimary: true }, }) }, - disabled: nic.primary, + disabled: nic.primary || !instanceStopped, }, { label: 'Edit', @@ -64,12 +66,15 @@ export function NetworkingTab() { // 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, }, ] @@ -83,46 +88,12 @@ 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)} - /> - setEditing(null)} - /> -
+

+ Network Interfaces +

@@ -140,6 +111,37 @@ export function NetworkingTab() { } />
+
+
+ +
+ {!instanceStopped && ( + + A network interface cannot be created or edited without{' '} + + stopping the instance + + + + )} +
+ + setCreateModalOpen(false)} + /> + setEditing(null)} + /> ) }