diff --git a/app/api/selectors.ts b/app/api/selectors.ts index f700d6cae6..c8870178bf 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -32,6 +32,7 @@ export type SystemUpdate = Readonly<{ version: string }> export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> export type IpPool = Readonly<{ pool?: string }> +export type ExternalSubnet = Readonly> export type FloatingIp = Readonly> export type Id = Readonly<{ id: string }> diff --git a/app/forms/external-subnet-create.tsx b/app/forms/external-subnet-create.tsx new file mode 100644 index 0000000000..38585f417e --- /dev/null +++ b/app/forms/external-subnet-create.tsx @@ -0,0 +1,123 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate } from 'react-router' + +import { api, queryClient, useApiMutation, type ExternalSubnetCreate } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { NameField } from '~/components/form/fields/NameField' +import { NumberField } from '~/components/form/fields/NumberField' +import { RadioField } from '~/components/form/fields/RadioField' +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +export const handle = titleCrumb('New External Subnet') + +export default function CreateExternalSubnetSideModalForm() { + const projectSelector = useProjectSelector() + const navigate = useNavigate() + + const createExternalSubnet = useApiMutation(api.externalSubnetCreate, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} created) + navigate(pb.externalSubnets(projectSelector)) + }, + }) + + const form = useForm({ + defaultValues: { + name: '', + description: '', + allocationType: 'auto' as 'auto' | 'explicit', + prefixLen: 24, + pool: '', + subnet: '', + }, + }) + + const allocationType = form.watch('allocationType') + + return ( + navigate(pb.externalSubnets(projectSelector))} + onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => { + const body: ExternalSubnetCreate = + allocationType === 'explicit' + ? { name, description, allocator: { type: 'explicit', subnet } } + : { + name, + description, + allocator: { + type: 'auto', + prefixLen, + poolSelector: pool ? { type: 'explicit', pool } : undefined, + }, + } + createExternalSubnet.mutate({ query: projectSelector, body }) + }} + loading={createExternalSubnet.isPending} + submitError={createExternalSubnet.error} + > + + + + {allocationType === 'auto' ? ( + <> + + {/* Subnet pool list endpoint not yet available + https://github.com/oxidecomputer/omicron/issues/9814 */} + + + ) : ( + + )} + + ) +} diff --git a/app/forms/external-subnet-edit.tsx b/app/forms/external-subnet-edit.tsx new file mode 100644 index 0000000000..55e4e39c79 --- /dev/null +++ b/app/forms/external-subnet-edit.tsx @@ -0,0 +1,114 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getExternalSubnetSelector, useExternalSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +const externalSubnetView = ({ + project, + externalSubnet, +}: { + project: string + externalSubnet: string +}) => + q(api.externalSubnetView, { + path: { externalSubnet }, + query: { project }, + }) + +const instanceList = (project: string) => + getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getExternalSubnetSelector(params) + await Promise.all([ + queryClient.fetchQuery(externalSubnetView(selector)), + queryClient.fetchQuery(instanceList(selector.project).optionsFn()), + ]) + return null +} + +export const handle = titleCrumb('Edit External Subnet') + +export default function EditExternalSubnetSideModalForm() { + const navigate = useNavigate() + + const subnetSelector = useExternalSubnetSelector() + const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project })) + + const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector)) + const { data: instances } = usePrefetchedQuery( + instanceList(subnetSelector.project).optionsFn() + ) + const instanceName = instances.items.find((i) => i.id === subnet.instanceId)?.name + + const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, { + onSuccess(updated) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {updated.name} updated) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: subnet }) + return ( + { + editExternalSubnet.mutate({ + path: { externalSubnet: subnetSelector.externalSubnet }, + query: { project: subnetSelector.project }, + body: { name, description }, + }) + }} + loading={editExternalSubnet.isPending} + submitError={editExternalSubnet.error} + > + + + + + {subnet.subnet} + + {instanceName ? ( + + ) : ( + + )} + + + + + + ) +} diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 997febe9be..95dd9d6919 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -487,7 +487,7 @@ const ProtocolFilters = ({ control }: { control: Control }) control={protocolForm.control} description={ <> - Enter a code (0) or range (e.g. 1–3). Leave blank for all + Enter a code (0) or range (e.g., 1–3). Leave blank for all traffic of type {selectedIcmpType}. } diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 9619697c78..d00f482ede 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -33,6 +33,7 @@ export const requireParams = } export const getProjectSelector = requireParams('project') +export const getExternalSubnetSelector = requireParams('project', 'externalSubnet') export const getFloatingIpSelector = requireParams('project', 'floatingIp') export const getInstanceSelector = requireParams('project', 'instance') export const getVpcSelector = requireParams('project', 'vpc') @@ -79,6 +80,7 @@ function useSelectedParams(getSelector: (params: AllParams) => T) { // params are present. Only the specified keys end up in the result object, but // we do not error if there are other params present in the query string. +export const useExternalSubnetSelector = () => useSelectedParams(getExternalSubnetSelector) export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector) export const useProjectSelector = () => useSelectedParams(getProjectSelector) export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 74e734a841..1a15d6baab 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -19,6 +19,7 @@ import { Networking16Icon, Snapshots16Icon, Storage16Icon, + Subnet16Icon, } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' @@ -68,6 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Snapshots', path: pb.snapshots(projectSelector) }, { value: 'Images', path: pb.projectImages(projectSelector) }, { value: 'VPCs', path: pb.vpcs(projectSelector) }, + { value: 'External Subnets', path: pb.externalSubnets(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, { value: 'Project Access', path: pb.projectAccess(projectSelector) }, @@ -111,6 +113,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { VPCs + + External Subnets + Floating IPs diff --git a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx new file mode 100644 index 0000000000..0e5c35dae8 --- /dev/null +++ b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx @@ -0,0 +1,276 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useState } from 'react' +import { useForm } from 'react-hook-form' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type ExternalSubnet, + type Instance, +} from '@oxide/api' +import { Subnet16Icon, Subnet24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' +import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +const EmptyState = () => ( + } + title="No external subnets" + body="Create an external subnet to see it here" + buttonText="New External Subnet" + buttonTo={pb.externalSubnetsNew(useProjectSelector())} + /> +) + +const subnetList = (project: string) => + getListQFn(api.externalSubnetList, { query: { project } }) +const instanceList = (project: string) => + getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } }) + +export const handle = makeCrumb('External Subnets', (p) => + pb.externalSubnets(getProjectSelector(p)) +) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project } = getProjectSelector(params) + await Promise.all([ + queryClient.fetchQuery(subnetList(project).optionsFn()), + queryClient.fetchQuery(instanceList(project).optionsFn()), + ]) + return null +} + +const colHelper = createColumnHelper() +const staticCols = [ + colHelper.accessor('name', {}), + colHelper.accessor('description', Columns.description), + colHelper.accessor('subnet', { + header: 'Subnet', + cell: (info) => {info.getValue()}, + }), + colHelper.accessor('instanceId', { + header: 'Attached to instance', + cell: (info) => , + }), +] + +export default function ExternalSubnetsPage() { + const [subnetToAttach, setSubnetToAttach] = useState(null) + const { project } = useProjectSelector() + const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) + const navigate = useNavigate() + + const { mutateAsync: externalSubnetDetach } = useApiMutation(api.externalSubnetDetach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} detached) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + const { mutateAsync: deleteExternalSubnet } = useApiMutation(api.externalSubnetDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {variables.path.externalSubnet} deleted) + }, + }) + + const makeActions = useCallback( + (subnet: ExternalSubnet): MenuAction[] => { + const instanceName = subnet.instanceId + ? instances.items.find((i) => i.id === subnet.instanceId)?.name + : undefined + const fromInstance = instanceName ? ( + <> + {' '} + from instance {instanceName} + + ) : null + + const isAttached = !!subnet.instanceId + const attachOrDetachAction = isAttached + ? { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + externalSubnetDetach({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + modalTitle: 'Detach External Subnet', + modalContent: ( +

+ Are you sure you want to detach external subnet {subnet.name} + {fromInstance}? +

+ ), + errorTitle: 'Error detaching external subnet', + }), + } + : { + label: 'Attach', + onActivate() { + setSubnetToAttach(subnet) + }, + } + return [ + { + label: 'Edit', + onActivate: () => { + // Seed cache so edit form can show data immediately + const { queryKey } = q(api.externalSubnetView, { + path: { externalSubnet: subnet.name }, + query: { project }, + }) + queryClient.setQueryData(queryKey, subnet) + navigate(pb.externalSubnetEdit({ project, externalSubnet: subnet.name })) + }, + }, + attachOrDetachAction, + { + label: 'Delete', + disabled: isAttached + ? 'This external subnet must be detached from the instance before it can be deleted' + : false, + onActivate: confirmDelete({ + doDelete: () => + deleteExternalSubnet({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + label: subnet.name, + }), + }, + ] + }, + [deleteExternalSubnet, externalSubnetDetach, navigate, project, instances] + ) + + const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: subnetList(project), + columns, + emptyState: , + }) + + return ( + <> + + }>External Subnets + } + summary="External subnets provide a range of IP addresses from a subnet pool that can be attached to instances." + links={[]} + /> + + + New External Subnet + + {table} + + {subnetToAttach && ( + setSubnetToAttach(null)} + /> + )} + + ) +} + +const AttachExternalSubnetModal = ({ + subnetName, + subnetCidr, + instances, + project, + onDismiss, +}: { + subnetName: string + subnetCidr: string + instances: Array + project: string + onDismiss: () => void +}) => { + const externalSubnetAttach = useApiMutation(api.externalSubnetAttach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} attached) + onDismiss() + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + + const form = useForm({ defaultValues: { instanceId: '' } }) + + return ( + { + externalSubnetAttach.mutate({ + path: { externalSubnet: subnetName }, + query: { project }, + body: { instance: instanceId }, + }) + }} + submitLabel="Attach" + submitError={externalSubnetAttach.error} + loading={externalSubnetAttach.isPending} + onDismiss={onDismiss} + > +

+ Attach subnet {subnetCidr} to an instance +

+ ({ value: i.id, label: i.name }))} + label="Instance" + required + placeholder="Select an instance" + /> +
+ ) +} diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 75b78a9c73..8706fa66cb 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' import { match } from 'ts-pattern' @@ -20,6 +21,7 @@ import { useApiMutation, usePrefetchedQuery, type ExternalIp, + type ExternalSubnet, type InstanceNetworkInterface, type InstanceState, type IpVersion, @@ -30,6 +32,8 @@ import { Badge } from '@oxide/design-system/ui' import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal' import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal' import { orderIps } from '~/components/ExternalIps' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' import { HL } from '~/components/HL' import { IpVersionBadge } from '~/components/IpVersionBadge' import { ListPlusCell } from '~/components/ListPlusCell' @@ -117,6 +121,15 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { }) ), queryClient.fetchQuery(q(api.floatingIpList, { query: { project, limit: ALL_ISH } })), + queryClient.fetchQuery( + q(api.externalSubnetList, { query: { project, limit: ALL_ISH } }) + ), + queryClient.fetchQuery( + q(api.instanceExternalSubnetList, { + path: { instance }, + query: { project }, + }) + ), // dupe of page-level fetch but that's fine, RQ dedupes queryClient.fetchQuery( q(api.instanceExternalIpList, { path: { instance }, query: { project } }) @@ -289,12 +302,28 @@ export default function NetworkingTab() { const [editing, setEditing] = useState(null) const [attachEphemeralModalOpen, setAttachEphemeralModalOpen] = useState(false) const [attachFloatingModalOpen, setAttachFloatingModalOpen] = useState(false) + const [attachSubnetModalOpen, setAttachSubnetModalOpen] = useState(false) // Fetch the floating IPs to show in the "Attach floating IP" modal const { data: ips } = usePrefetchedQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) + // Fetch external subnets for this project and this instance + const { data: allSubnets } = usePrefetchedQuery( + q(api.externalSubnetList, { query: { project, limit: ALL_ISH } }) + ) + const { data: instanceSubnets } = usePrefetchedQuery( + q(api.instanceExternalSubnetList, { + path: { instance: instanceName }, + query: { project }, + }) + ) + const availableSubnets = useMemo( + () => allSubnets.items.filter((s) => !s.instanceId), + [allSubnets] + ) + const nics = usePrefetchedQuery( q(api.instanceNetworkInterfaceList, { query: { ...instanceSelector, limit: ALL_ISH }, @@ -445,6 +474,60 @@ export default function NetworkingTab() { }, }) + const { mutateAsync: externalSubnetDetach } = useApiMutation(api.externalSubnetDetach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} detached) + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + + const makeSubnetActions = useCallback( + (subnet: ExternalSubnet): MenuAction[] => [ + { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + externalSubnetDetach({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + modalTitle: 'Detach External Subnet', + modalContent: ( +

+ Are you sure you want to detach external subnet {subnet.name} from{' '} + {instanceName}? +

+ ), + errorTitle: 'Error detaching external subnet', + }), + }, + ], + [externalSubnetDetach, instanceName, project] + ) + + const subnetColHelper = createColumnHelper() + const subnetCols = useMemo( + () => [ + subnetColHelper.accessor('name', {}), + subnetColHelper.accessor('subnet', { header: 'Subnet' }), + subnetColHelper.accessor('description', Columns.description), + ], + [subnetColHelper] + ) + const subnetTableCols = useColsWithActions(subnetCols, makeSubnetActions) + const subnetTableInstance = useReactTable({ + columns: subnetTableCols, + data: instanceSubnets.items, + getCoreRowModel: getCoreRowModel(), + }) + const makeIpActions = useCallback( (externalIp: ExternalIp): MenuAction[] => { const copyAction = { @@ -633,6 +716,105 @@ export default function NetworkingTab() { setEditing(null)} /> )} + + + + + + + + {instanceSubnets.items.length > 0 ? ( + + ) : ( + + } + title="No external subnets" + body="Attach an external subnet to see it here" + /> + + )} + + + {attachSubnetModalOpen && ( + setAttachSubnetModalOpen(false)} + /> + )} + ) } + +const AttachExternalSubnetModal = ({ + subnets, + instanceName, + project, + onDismiss, +}: { + subnets: ExternalSubnet[] + instanceName: string + project: string + onDismiss: () => void +}) => { + const externalSubnetAttach = useApiMutation(api.externalSubnetAttach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} attached) + onDismiss() + }, + onError: (err) => { + addToast({ title: 'Error', content: err.message, variant: 'error' }) + }, + }) + + const form = useForm({ defaultValues: { subnetName: '' } }) + + return ( + { + externalSubnetAttach.mutate({ + path: { externalSubnet: subnetName }, + query: { project }, + body: { instance: instanceName }, + }) + }} + submitLabel="Attach" + submitError={externalSubnetAttach.error} + loading={externalSubnetAttach.isPending} + onDismiss={onDismiss} + > + ({ + value: s.name, + label: `${s.name} (${s.subnet})`, + }))} + label="External subnet" + required + placeholder="Select an external subnet" + /> + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index dbd05d4380..e2c53dc097 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -479,6 +479,21 @@ export const routes = createRoutesFromElements( + + import('./pages/project/external-subnets/ExternalSubnetsPage').then(convert) + } + > + + import('./forms/external-subnet-create').then(convert)} + /> + import('./forms/external-subnet-edit').then(convert)} + /> + import('./pages/project/floating-ips/FloatingIpsPage').then(convert) diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index 3dcb8ddac3..4f3d714dc6 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -129,6 +129,48 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/disks", }, ], + "externalSubnetEdit (/projects/p/external-subnets/es/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], + "externalSubnets (/projects/p/external-subnets)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], + "externalSubnetsNew (/projects/p/external-subnets-new)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], "floatingIpEdit (/projects/p/floating-ips/f/edit)": [ { "label": "Projects", diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 368731c03d..d8eb0752ea 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -18,6 +18,7 @@ import { pb } from './path-builder' const params = { affinityGroup: 'ag', antiAffinityGroup: 'aag', + externalSubnet: 'es', floatingIp: 'f', gateway: 'g', project: 'p', @@ -52,6 +53,9 @@ test('path builder', () => { "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", "disksNew": "/projects/p/disks-new", + "externalSubnetEdit": "/projects/p/external-subnets/es/edit", + "externalSubnets": "/projects/p/external-subnets", + "externalSubnetsNew": "/projects/p/external-subnets-new", "floatingIpEdit": "/projects/p/floating-ips/f/edit", "floatingIps": "/projects/p/floating-ips", "floatingIpsNew": "/projects/p/floating-ips-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 6d55092139..fcf4f0c5a9 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -93,6 +93,11 @@ export const pb = { `${pb.vpcInternetGateways(params)}/${params.gateway}`, // vpcInternetGatewaysNew: (params: Vpc) => `${vpcBase(params)}/internet-gateways-new`, // + externalSubnets: (params: PP.Project) => `${projectBase(params)}/external-subnets`, + externalSubnetsNew: (params: PP.Project) => `${projectBase(params)}/external-subnets-new`, + externalSubnetEdit: (params: PP.ExternalSubnet) => + `${pb.externalSubnets(params)}/${params.externalSubnet}/edit`, + floatingIps: (params: PP.Project) => `${projectBase(params)}/floating-ips`, floatingIpsNew: (params: PP.Project) => `${projectBase(params)}/floating-ips-new`, floatingIpEdit: (params: PP.FloatingIp) => diff --git a/app/util/path-params.ts b/app/util/path-params.ts index ee4549a86d..ed103c65d4 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -19,6 +19,7 @@ export type Image = Required export type Snapshot = Required export type SiloImage = Required export type IpPool = Required +export type ExternalSubnet = Required export type FloatingIp = Required export type FirewallRule = Required export type VpcRouter = Required diff --git a/mock-api/external-subnet.ts b/mock-api/external-subnet.ts new file mode 100644 index 0000000000..0b2d708a2d --- /dev/null +++ b/mock-api/external-subnet.ts @@ -0,0 +1,55 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { ExternalSubnet } from '@oxide/api' + +import { instance } from './instance' +import type { Json } from './json-type' +import { project } from './project' +import { subnetPool1, subnetPoolMember1 } from './subnet-pool' + +export const externalSubnet1: Json = { + id: 'e1a2b3c4-d5e6-f7a8-b9c0-d1e2f3a4b5c6', + name: 'web-subnet', + description: 'Subnet for web services', + project_id: project.id, + instance_id: undefined, + subnet: '10.128.1.0/24', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnet2: Json = { + id: 'f2b3c4d5-e6f7-a8b9-c0d1-e2f3a4b5c6d7', + name: 'db-subnet', + description: 'Subnet for database tier', + project_id: project.id, + instance_id: instance.id, + subnet: '10.128.2.0/24', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnet3: Json = { + id: 'a3c4d5e6-f7a8-b9c0-d1e2-f3a4b5c6d7e8', + name: 'staging-subnet', + description: 'Staging environment subnet', + project_id: project.id, + instance_id: undefined, + subnet: '10.128.3.0/28', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnets = [externalSubnet1, externalSubnet2, externalSubnet3] diff --git a/mock-api/index.ts b/mock-api/index.ts index b63cbf5650..17fb55e39e 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -9,6 +9,7 @@ export * from './affinity-group' export * from './disk' export * from './external-ip' +export * from './external-subnet' export * from './floating-ip' export * from './image' export * from './instance' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 398868e27e..0b6dc706c4 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -226,6 +226,24 @@ export const lookup = { return disk }, + externalSubnet({ + externalSubnet: id, + ...projectSelector + }: Sel.ExternalSubnet): Json { + if (!id) throw notFoundErr('no external subnet specified') + + if (isUuid(id)) { + ensureNoParentSelectors('external subnet', projectSelector) + return lookupById(db.externalSubnets, id) + } + + const project = lookup.project(projectSelector) + const externalSubnet = db.externalSubnets.find( + (s) => s.project_id === project.id && s.name === id + ) + if (!externalSubnet) throw notFoundErr(`external subnet '${id}'`) + return externalSubnet + }, floatingIp({ floatingIp: id, ...projectSelector }: Sel.FloatingIp): Json { if (!id) throw notFoundErr('no floating IP specified') @@ -543,6 +561,7 @@ const initDb = { deviceTokens: [...mock.deviceTokens], disks: [...mock.disks], diskBulkImportState: new Map(), + externalSubnets: [...mock.externalSubnets], floatingIps: [...mock.floatingIps], userGroups: [...mock.userGroups], /** Join table for `users` and `userGroups` */ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index e2d40ca220..9853da802b 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -282,13 +282,93 @@ export const handlers = makeHandlers({ return 204 }, - externalSubnetList: NotImplemented, - externalSubnetCreate: NotImplemented, - externalSubnetView: NotImplemented, - externalSubnetUpdate: NotImplemented, - externalSubnetDelete: NotImplemented, - externalSubnetAttach: NotImplemented, - externalSubnetDetach: NotImplemented, + externalSubnetList({ query }) { + const project = lookup.project(query) + const subnets = db.externalSubnets.filter((s) => s.project_id === project.id) + return paginated(query, subnets) + }, + externalSubnetCreate({ body, query }) { + const project = lookup.project(query) + errIfExists(db.externalSubnets, { name: body.name, project_id: project.id }) + + // For mock purposes, derive a subnet CIDR from the allocator + let subnet: string + let subnetPoolId: string + let subnetPoolMemberId: string + + if (body.allocator.type === 'explicit') { + subnet = body.allocator.subnet + // In the mock, just use the first known pool/member + subnetPoolId = db.externalSubnets[0]?.subnet_pool_id || 'unknown' + subnetPoolMemberId = db.externalSubnets[0]?.subnet_pool_member_id || 'unknown' + } else { + // type === 'auto': generate a CIDR based on prefix length + const prefixLen = body.allocator.prefix_len + const idx = db.externalSubnets.length + 1 + subnet = `10.128.${idx}.0/${prefixLen}` + subnetPoolId = db.externalSubnets[0]?.subnet_pool_id || 'unknown' + subnetPoolMemberId = db.externalSubnets[0]?.subnet_pool_member_id || 'unknown' + } + + const newSubnet: Json = { + id: uuid(), + project_id: project.id, + name: body.name, + description: body.description, + subnet, + subnet_pool_id: subnetPoolId, + subnet_pool_member_id: subnetPoolMemberId, + ...getTimestamps(), + } + db.externalSubnets.push(newSubnet) + return json(newSubnet, { status: 201 }) + }, + externalSubnetView: ({ path, query }) => lookup.externalSubnet({ ...path, ...query }), + externalSubnetUpdate: ({ path, query, body }) => { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + if (body.name) { + if (body.name !== externalSubnet.name) { + errIfExists(db.externalSubnets, { + name: body.name, + project_id: externalSubnet.project_id, + }) + } + externalSubnet.name = body.name + } + updateDesc(externalSubnet, body) + return externalSubnet + }, + externalSubnetDelete({ path, query }) { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + db.externalSubnets = db.externalSubnets.filter((s) => s.id !== externalSubnet.id) + return 204 + }, + externalSubnetAttach({ path: { externalSubnet }, query: { project }, body }) { + const dbSubnet = lookup.externalSubnet({ externalSubnet, project }) + if (dbSubnet.instance_id) { + throw json( + { + error_code: 'InvalidRequest', + message: + 'external subnet cannot be attached to one instance while still attached to another', + }, + { status: 400 } + ) + } + const dbInstance = lookup.instance({ + instance: body.instance, + project: isUuid(body.instance) ? undefined : project, + }) + dbSubnet.instance_id = dbInstance.id + return dbSubnet + }, + externalSubnetDetach({ path, query }) { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + db.externalSubnets = db.externalSubnets.map((s) => + s.id !== externalSubnet.id ? s : { ...s, instance_id: undefined } + ) + return externalSubnet + }, floatingIpCreate({ body, query }) { const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) @@ -2193,7 +2273,11 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - instanceExternalSubnetList: NotImplemented, + instanceExternalSubnetList({ path, query }) { + const instance = lookup.instance({ ...path, ...query }) + const items = db.externalSubnets.filter((s) => s.instance_id === instance.id) + return { items, next_page: null } + }, instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, diff --git a/mock-api/subnet-pool.ts b/mock-api/subnet-pool.ts new file mode 100644 index 0000000000..10e5ea02de --- /dev/null +++ b/mock-api/subnet-pool.ts @@ -0,0 +1,25 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Minimal subnet pool seed data for external subnet allocation. +// There's no UI for subnet pools themselves yet, but external subnets +// reference them. + +export const subnetPool1 = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + name: 'default-v4-subnet-pool', +} + +export const subnetPoolMember1 = { + id: 'b2c3d4e5-f6a7-8901-bcde-f12345678901', + subnet_pool_id: subnetPool1.id, + // Range that external subnets are allocated from + subnet: '10.128.0.0/16', + min_prefix_length: 20, + max_prefix_length: 28, +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000000..d25e1c82bc --- /dev/null +++ b/plan.md @@ -0,0 +1,5 @@ +- Does the instance need to be stopped to attach/detach subnets? no +- Is there a hard coded limit in the API to the number of attached subnets? +- Get form right: make sure it covers all possibilities +- Add subnet detail side modal on list page (with URL) and on instance networking tab (no URL) +- What's up with subnet pools? turns out they're like IP pools and they need their own entire UI diff --git a/test/e2e/external-subnets.e2e.ts b/test/e2e/external-subnets.e2e.ts new file mode 100644 index 0000000000..d76eca4710 --- /dev/null +++ b/test/e2e/external-subnets.e2e.ts @@ -0,0 +1,321 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + clickRowAction, + closeToast, + expect, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' + +const externalSubnetsPage = '/projects/mock-project/external-subnets' + +test('can navigate to external subnets via sidebar', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await page.getByRole('link', { name: 'External Subnets' }).click() + await expect(page).toHaveURL(externalSubnetsPage) + await expect(page.getByRole('heading', { name: 'External Subnets' })).toBeVisible() +}) + +test('displays seeded external subnets in table', async ({ page }) => { + await page.goto(externalSubnetsPage) + + const table = page.getByRole('table') + await expectRowVisible(table, { + name: 'web-subnet', + description: 'Subnet for web services', + Subnet: '10.128.1.0/24', + }) + await expectRowVisible(table, { + name: 'db-subnet', + description: 'Subnet for database tier', + Subnet: '10.128.2.0/24', + 'Attached to instance': 'db1', + }) + await expectRowVisible(table, { + name: 'staging-subnet', + description: 'Staging environment subnet', + Subnet: '10.128.3.0/28', + }) +}) + +test('can create an external subnet with auto allocation', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'New External Subnet' }).click() + + await expectVisible(page, [ + 'role=heading[name*="Create external subnet"]', + 'role=textbox[name="Name"]', + 'role=textbox[name="Description"]', + 'role=button[name="Create external subnet"]', + ]) + + // Auto should be selected by default + await expect(page.getByRole('radio', { name: 'Auto' })).toBeChecked() + + await page.fill('input[name=name]', 'my-new-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('A test subnet') + + await page.getByRole('button', { name: 'Create external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet my-new-subnet created') + + await expectRowVisible(page.getByRole('table'), { + name: 'my-new-subnet', + description: 'A test subnet', + }) +}) + +test('can create an external subnet with explicit CIDR', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'New External Subnet' }).click() + + await page.fill('input[name=name]', 'explicit-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('Explicit CIDR subnet') + + // Switch to explicit mode + await page.getByRole('radio', { name: 'Explicit' }).click() + + // Prefix length and pool fields should be hidden, subnet CIDR should appear + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeHidden() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeVisible() + + await page.getByRole('textbox', { name: 'Subnet CIDR' }).fill('10.128.5.0/24') + + await page.getByRole('button', { name: 'Create external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet explicit-subnet created') + + await expectRowVisible(page.getByRole('table'), { + name: 'explicit-subnet', + Subnet: '10.128.5.0/24', + }) +}) + +test('can update an external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + await clickRowAction(page, 'web-subnet', 'Edit') + + await expectVisible(page, [ + 'role=heading[name*="Edit external subnet"]', + 'role=textbox[name="Name"]', + 'role=textbox[name="Description"]', + 'role=button[name="Update external subnet"]', + ]) + + // Read-only properties should be visible in the side modal + const modal = page.getByTestId('sidemodal-scroll-container') + await expect(modal.getByText('10.128.1.0/24')).toBeVisible() + + await page.fill('input[name=name]', 'renamed-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('Updated description') + await page.getByRole('button', { name: 'Update external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet renamed-subnet updated') + + await expectRowVisible(page.getByRole('table'), { + name: 'renamed-subnet', + description: 'Updated description', + }) +}) + +test('can update just the description', async ({ page }) => { + await page.goto(`${externalSubnetsPage}/web-subnet/edit`) + + await page.getByRole('textbox', { name: 'Description' }).fill('New description only') + await page.getByRole('button', { name: 'Update external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet web-subnet updated') + + await expectRowVisible(page.getByRole('table'), { + name: 'web-subnet', + description: 'New description only', + }) +}) + +test('can delete an unattached external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + + await clickRowAction(page, 'web-subnet', 'Delete') + await expect(page.getByText('Are you sure you want to delete web-subnet?')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, 'External subnet web-subnet deleted') + + await expect(page.getByRole('cell', { name: 'web-subnet' })).toBeHidden() +}) + +test('cannot delete an attached external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + + // db-subnet is attached to db1, so delete should be disabled + const actionsButton = page + .getByRole('row', { name: 'db-subnet' }) + .getByRole('button', { name: 'Row actions' }) + await actionsButton.click() + + const deleteButton = page.getByRole('menuitem', { name: 'Delete' }) + await expect(deleteButton).toBeDisabled() + await deleteButton.hover() + await expect(page.getByText('must be detached')).toBeVisible() +}) + +test('can detach and reattach an external subnet from the list page', async ({ page }) => { + await page.goto(externalSubnetsPage) + + // db-subnet is attached to db1 + await expectRowVisible(page.getByRole('table'), { + name: 'db-subnet', + 'Attached to instance': 'db1', + }) + + // Detach it + await clickRowAction(page, 'db-subnet', 'Detach') + await expect(page.getByText('Are you sure you want to detach')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'External subnet db-subnet detached') + + // After detaching, the instance cell should no longer show db1 + await expect(page.getByRole('dialog')).toBeHidden() + + // Now reattach it via the Attach action + await clickRowAction(page, 'db-subnet', 'Attach') + await expect(page.getByRole('heading', { name: 'Attach external subnet' })).toBeVisible() + await page.getByLabel('Instance').click() + await page.getByRole('option', { name: 'db1' }).click() + await page.getByRole('button', { name: 'Attach' }).click() + + await expect(page.getByRole('dialog')).toBeHidden() + await expectToast(page, 'External subnet db-subnet attached') + + await expectRowVisible(page.getByRole('table'), { + name: 'db-subnet', + 'Attached to instance': 'db1', + }) +}) + +test('Instance networking tab — shows attached external subnets', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { + name: 'db-subnet', + Subnet: '10.128.2.0/24', + }) + + // Unattached subnets should not appear + await expect(subnetTable.getByRole('cell', { name: 'web-subnet' })).toBeHidden() + await expect(subnetTable.getByRole('cell', { name: 'staging-subnet' })).toBeHidden() +}) + +test('Instance networking tab — detach external subnet', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { name: 'db-subnet' }) + + await clickRowAction(page, 'db-subnet', 'Detach') + await expect(page.getByText('Are you sure you want to detach')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, 'External subnet db-subnet detached') + + await expect(subnetTable.getByRole('cell', { name: 'db-subnet' })).toBeHidden() +}) + +test('Instance networking tab — attach external subnet', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const attachButton = page.getByRole('button', { name: 'Attach external subnet' }) + await expect(attachButton).toBeEnabled() + + await attachButton.click() + await expect(page.getByRole('heading', { name: 'Attach external subnet' })).toBeVisible() + + // Select web-subnet (unattached) + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /web-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + + await expect(page.getByRole('dialog')).toBeHidden() + await expectToast(page, 'External subnet web-subnet attached') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { + name: 'web-subnet', + Subnet: '10.128.1.0/24', + }) +}) + +test('Instance networking tab — attach button disabled when no available subnets', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + // Attach both unattached subnets to exhaust the pool + const attachButton = page.getByRole('button', { name: 'Attach external subnet' }) + + // Attach web-subnet + await attachButton.click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /web-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(dialog).toBeHidden() + await closeToast(page) + + // Attach staging-subnet + await attachButton.click() + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /staging-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(dialog).toBeHidden() + await closeToast(page) + + // Now all subnets are attached, so the button should be disabled + await expect(attachButton).toBeDisabled() +}) + +test('external subnet edit form accessible via direct URL', async ({ page }) => { + await page.goto(`${externalSubnetsPage}/web-subnet/edit`) + + await expect(page.getByRole('heading', { name: /Edit external subnet/i })).toBeVisible() + + // Read-only properties should be present in the side modal + const modal = page.getByTestId('sidemodal-scroll-container') + await expect(modal.getByText('10.128.1.0/24')).toBeVisible() + + await expect(page.getByRole('textbox', { name: 'Name' })).toHaveValue('web-subnet') +}) + +test('create form toggles between auto and explicit fields', async ({ page }) => { + await page.goto(`${externalSubnetsPage}-new`) + + // Auto mode: prefix length visible, subnet CIDR hidden + await expect(page.getByRole('radio', { name: 'Auto' })).toBeChecked() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeHidden() + + // Switch to explicit + await page.getByRole('radio', { name: 'Explicit' }).click() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeHidden() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeVisible() + + // Switch back to auto + await page.getByRole('radio', { name: 'Auto' }).click() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeHidden() +}) diff --git a/test/e2e/instance-networking.e2e.ts b/test/e2e/instance-networking.e2e.ts index 6207af8487..271487b002 100644 --- a/test/e2e/instance-networking.e2e.ts +++ b/test/e2e/instance-networking.e2e.ts @@ -65,7 +65,7 @@ test('Instance networking tab — NIC table', async ({ page }) => { await page.getByRole('textbox', { name: 'Name' }).fill('nic-2') await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByLabel('Subnet').click() + await page.getByRole('dialog').getByLabel('Subnet').click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() await page .getByRole('dialog') diff --git a/test/e2e/network-interface-create.e2e.ts b/test/e2e/network-interface-create.e2e.ts index 206b1fd4da..9d54f0994d 100644 --- a/test/e2e/network-interface-create.e2e.ts +++ b/test/e2e/network-interface-create.e2e.ts @@ -22,7 +22,7 @@ test('can create a NIC with a specified IP address', async ({ page }) => { await page.getByLabel('Name').fill('nic-1') await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() // Select IPv4 only @@ -52,7 +52,7 @@ test('can create a NIC with a blank IP address', async ({ page }) => { await page.getByLabel('Name').fill('nic-2') await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() // Dual-stack is selected by default, so both fields should be visible @@ -92,7 +92,7 @@ test('can create a NIC with IPv6 only', async ({ page }) => { await page.getByLabel('Name').fill('nic-3') await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() // Select IPv6 only @@ -117,7 +117,7 @@ test('can create a NIC with dual-stack and explicit IPs', async ({ page }) => { await page.getByLabel('Name').fill('nic-4') await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() // Dual-stack is selected by default diff --git a/test/e2e/z-index.e2e.ts b/test/e2e/z-index.e2e.ts index 175bc554b2..4c6c3c1a15 100644 --- a/test/e2e/z-index.e2e.ts +++ b/test/e2e/z-index.e2e.ts @@ -25,7 +25,7 @@ test('Dropdown content in SidebarModal shows on screen', async ({ page }) => { // clickable means they are not obscured due to having a too-low z-index await page.getByLabel('VPC', { exact: true }).click() await page.getByRole('option', { name: 'mock-vpc' }).click() - await page.getByRole('button', { name: 'Subnet' }).click() + await page.getByRole('dialog').getByRole('button', { name: 'Subnet' }).click() await page.getByRole('option', { name: 'mock-subnet', exact: true }).click() const sidebar = page.getByRole('dialog', { name: 'Add network interface' })