From f3f51313eec642938ff48414dc59d31fb56939fb Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 28 Jul 2026 12:33:00 +0100 Subject: [PATCH] Initial draft --- app/forms/internet-gateway-create.tsx | 63 +++ .../internet-gateway-ip-address-create.tsx | 85 ++++ app/forms/internet-gateway-ip-pool-create.tsx | 98 +++++ .../project/vpcs/InternetGatewayPage.tsx | 396 ++++++++++++++++++ app/pages/project/vpcs/VpcGatewaysTab.tsx | 103 ++++- app/pages/project/vpcs/gateway-data.ts | 29 +- .../project/vpcs/internet-gateway-edit.tsx | 207 --------- app/routes.tsx | 30 +- .../__snapshots__/path-builder.spec.ts.snap | 82 +++- app/util/path-builder.spec.ts | 3 + app/util/path-builder.ts | 8 +- mock-api/internet-gateway.ts | 4 +- mock-api/msw/db.ts | 19 + mock-api/msw/handlers.ts | 98 ++++- test/e2e/vpcs.e2e.ts | 146 +++++-- 15 files changed, 1101 insertions(+), 270 deletions(-) create mode 100644 app/forms/internet-gateway-create.tsx create mode 100644 app/forms/internet-gateway-ip-address-create.tsx create mode 100644 app/forms/internet-gateway-ip-pool-create.tsx create mode 100644 app/pages/project/vpcs/InternetGatewayPage.tsx delete mode 100644 app/pages/project/vpcs/internet-gateway-edit.tsx diff --git a/app/forms/internet-gateway-create.tsx b/app/forms/internet-gateway-create.tsx new file mode 100644 index 0000000000..577e78b541 --- /dev/null +++ b/app/forms/internet-gateway-create.tsx @@ -0,0 +1,63 @@ +/* + * 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 InternetGatewayCreate } 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 { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const defaultValues: InternetGatewayCreate = { + name: '', + description: '', +} + +export const handle = titleCrumb('New Internet Gateway') + +export default function InternetGatewayCreateForm() { + const vpcSelector = useVpcSelector() + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.vpcInternetGateways(vpcSelector)) + + const createGateway = useApiMutation(api.internetGatewayCreate, { + onSuccess(gateway) { + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {gateway.name} created) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + createGateway.mutate({ query: vpcSelector, body })} + loading={createGateway.isPending} + submitError={createGateway.error} + > + + + + + ) +} diff --git a/app/forms/internet-gateway-ip-address-create.tsx b/app/forms/internet-gateway-ip-address-create.tsx new file mode 100644 index 0000000000..5ca25a4d27 --- /dev/null +++ b/app/forms/internet-gateway-ip-address-create.tsx @@ -0,0 +1,85 @@ +/* + * 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 InternetGatewayIpAddressCreate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { noPasswordManager, TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useInternetGatewaySelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { validateIp } from '~/util/ip' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +export const handle = titleCrumb('Attach IP Address') + +const defaultValues: InternetGatewayIpAddressCreate = { + name: '', + description: '', + address: '', +} + +export default function InternetGatewayIpAddressCreateForm() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway })) + + const attachAddress = useApiMutation(api.internetGatewayIpAddressCreate, { + onSuccess(address) { + queryClient.invalidateEndpoint('internetGatewayIpAddressList') + // prettier-ignore + addToast(<>IP address {address.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + attachAddress.mutate({ query: { project, vpc, gateway }, body })} + loading={attachAddress.isPending} + submitError={attachAddress.error} + > + + + + + + ) +} diff --git a/app/forms/internet-gateway-ip-pool-create.tsx b/app/forms/internet-gateway-ip-pool-create.tsx new file mode 100644 index 0000000000..05427f72a7 --- /dev/null +++ b/app/forms/internet-gateway-ip-pool-create.tsx @@ -0,0 +1,98 @@ +/* + * 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, + q, + queryClient, + sortPools, + useApiMutation, + usePrefetchedQuery, + type InternetGatewayIpPoolCreate, +} 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 { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { toPoolItem } from '~/components/PoolListboxItem' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useInternetGatewaySelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const poolList = q(api.ipPoolList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + await queryClient.prefetchQuery(poolList) + return null +} + +export const handle = titleCrumb('Attach IP Pool') + +const defaultValues: InternetGatewayIpPoolCreate = { + name: '', + description: '', + ipPool: '', +} + +export default function InternetGatewayIpPoolCreateForm() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + + const { data: pools } = usePrefetchedQuery(poolList) + + const onDismiss = () => navigate(pb.vpcInternetGateway({ project, vpc, gateway })) + + const attachPool = useApiMutation(api.internetGatewayIpPoolCreate, { + onSuccess(pool) { + queryClient.invalidateEndpoint('internetGatewayIpPoolList') + // prettier-ignore + addToast(<>IP pool {pool.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + attachPool.mutate({ query: { project, vpc, gateway }, body })} + loading={attachPool.isPending} + submitError={attachPool.error} + > + + + + + + ) +} diff --git a/app/pages/project/vpcs/InternetGatewayPage.tsx b/app/pages/project/vpcs/InternetGatewayPage.tsx new file mode 100644 index 0000000000..de85104552 --- /dev/null +++ b/app/pages/project/vpcs/InternetGatewayPage.tsx @@ -0,0 +1,396 @@ +/* + * 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, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useCallback, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { Gateway16Icon, Gateway24Icon } from '@oxide/design-system/icons/react' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type InternetGatewayIpAddress, + type InternetGatewayIpPool, +} from '~/api' +import { CopyIdItem } from '~/components/CopyIdItem' +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { CardBlock } from '~/ui/lib/CardBlock' +import { CopyableIp } from '~/ui/lib/CopyableIp' +import { CreateLink } from '~/ui/lib/CreateButton' +import { Divider } from '~/ui/lib/Divider' +import * as DropdownMenu from '~/ui/lib/DropdownMenu' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableEmptyBox } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { + gatewayIpAddressList, + gatewayIpPoolList, + routeList, + routerList, + useGatewayRoutes, +} from './gateway-data' + +export const handle = makeCrumb((p) => p.gateway!) + +const gatewayView = ({ project, vpc, gateway }: PP.VpcInternetGateway) => + q(api.internetGatewayView, { path: { gateway }, query: { project, vpc } }) + +const siloIpPoolList = getListQFn(api.ipPoolList, { query: { limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getInternetGatewaySelector(params) + const { project, vpc } = selector + await Promise.all([ + queryClient.prefetchQuery(gatewayView(selector)), + queryClient.prefetchQuery(gatewayIpPoolList(selector).optionsFn()), + queryClient.prefetchQuery(gatewayIpAddressList(selector).optionsFn()), + ...(await queryClient.fetchQuery(routerList({ project, vpc }).optionsFn())).items.map( + (router) => + queryClient.prefetchQuery( + routeList({ project, vpc, router: router.name }).optionsFn() + ) + ), + queryClient.fetchQuery(siloIpPoolList.optionsFn()).then((pools) => { + for (const pool of pools.items) { + // IpPoolCell uses the errors-allowed query shape, so seed that exact + // cache entry instead of the normal ipPoolView query. + const { queryKey } = ipPoolErrorsAllowedQuery(pool.id) + queryClient.setQueryData(queryKey, { type: 'success', data: pool }) + } + }), + ] satisfies Promise[]) + return null +} + +const poolColHelper = createColumnHelper() +const addressColHelper = createColumnHelper() + +type GatewayRoute = { router: string; route: string } +const routeColHelper = createColumnHelper() + +export default function InternetGatewayPage() { + const { project, vpc, gateway } = useInternetGatewaySelector() + const navigate = useNavigate() + const { data: gatewayData } = usePrefetchedQuery(gatewayView({ project, vpc, gateway })) + const { data: gatewayIpPools } = usePrefetchedQuery( + gatewayIpPoolList({ project, vpc, gateway }).optionsFn() + ) + const { data: gatewayIpAddresses } = usePrefetchedQuery( + gatewayIpAddressList({ project, vpc, gateway }).optionsFn() + ) + const matchingRoutes = useGatewayRoutes({ project, vpc, gateway }) + + const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, { + onSuccess() { + navigate(pb.vpcInternetGateways({ project, vpc })) + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {gateway} deleted) + }, + }) + + const { mutateAsync: detachPool } = useApiMutation(api.internetGatewayIpPoolDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayIpPoolList') + // prettier-ignore + addToast(<>IP pool {variables.path.pool} detached) + }, + }) + + const { mutateAsync: detachAddress } = useApiMutation( + api.internetGatewayIpAddressDelete, + { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayIpAddressList') + // prettier-ignore + addToast(<>IP address {variables.path.address} detached) + }, + } + ) + + const makePoolActions = useCallback( + (pool: InternetGatewayIpPool): MenuAction[] => [ + { + label: 'Detach', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + detachPool({ + path: { pool: pool.name }, + query: { project, vpc, gateway }, + }), + errorTitle: 'Could not detach IP pool', + modalTitle: 'Detach IP pool', + modalContent: ( +

+ Are you sure you want to detach IP pool {pool.name} from gateway{' '} + {gateway}? +

+ ), + actionType: 'danger', + }), + }, + ], + [detachPool, project, vpc, gateway] + ) + + const makeAddressActions = useCallback( + (address: InternetGatewayIpAddress): MenuAction[] => [ + { + label: 'Detach', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + detachAddress({ + path: { address: address.name }, + query: { project, vpc, gateway }, + }), + errorTitle: 'Could not detach IP address', + modalTitle: 'Detach IP address', + modalContent: ( +

+ Are you sure you want to detach IP address {address.name} from + gateway {gateway}? +

+ ), + actionType: 'danger', + }), + }, + ], + [detachAddress, project, vpc, gateway] + ) + + const poolsTable = useReactTable({ + columns: useColsWithActions( + [ + poolColHelper.accessor('name', {}), + poolColHelper.accessor('description', Columns.description), + poolColHelper.accessor('ipPoolId', { + header: 'IP pool', + cell: (info) => , + }), + poolColHelper.accessor('timeCreated', Columns.timeCreated), + ], + makePoolActions + ), + data: gatewayIpPools.items, + getCoreRowModel: getCoreRowModel(), + }) + + const addressesTable = useReactTable({ + columns: useColsWithActions( + [ + addressColHelper.accessor('name', {}), + addressColHelper.accessor('description', Columns.description), + addressColHelper.accessor('address', { + cell: (info) => , + }), + addressColHelper.accessor('timeCreated', Columns.timeCreated), + ], + makeAddressActions + ), + data: gatewayIpAddresses.items, + getCoreRowModel: getCoreRowModel(), + }) + + const routesData = useMemo( + () => (matchingRoutes || []).map(([router, route]) => ({ router, route: route.name })), + [matchingRoutes] + ) + const routesTable = useReactTable({ + columns: useMemo( + () => [ + routeColHelper.accessor('router', { + header: 'Router', + cell: (info) => ( + + {info.getValue()} + + ), + }), + routeColHelper.accessor('route', { header: 'Route' }), + ], + [project, vpc] + ), + data: routesData, + getCoreRowModel: getCoreRowModel(), + }) + + useQuickActions( + () => [ + { + value: 'Attach IP pool', + navGroup: 'Actions', + action: pb.vpcInternetGatewayIpPoolsNew({ project, vpc, gateway }), + }, + { + value: 'Attach IP address', + navGroup: 'Actions', + action: pb.vpcInternetGatewayIpAddressesNew({ project, vpc, gateway }), + }, + ], + [project, vpc, gateway] + ) + + return ( + <> + + }>{gateway} +
+ } + summary="An internet gateway connects a VPC to the internet, using addresses from an attached IP pool or an attached IP address." + links={[docLinks.gateways]} + /> + + + + deleteGateway({ + path: { gateway }, + query: { project, vpc, cascade: true }, + }), + label: gateway, + resourceKind: 'internet gateway', + extraContent: + 'Any attached IP pools and IP addresses will be detached, and routes targeting this gateway will be deleted.', + })} + className="destructive" + /> + +
+
+ + + + + + + +
+ + + + Attach IP pool + + + + {gatewayIpPools.items.length > 0 ? ( + + ) : ( + + + + )} + + + + + + + Attach IP address + + + + {gatewayIpAddresses.items.length > 0 ? ( +
+ ) : ( + + + + )} + + + + + + + {routesData.length > 0 ? ( +
+ ) : ( + + + + )} + + + + + + ) +} diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx index bbb095afaa..cace85864e 100644 --- a/app/pages/project/vpcs/VpcGatewaysTab.tsx +++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx @@ -8,17 +8,24 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper } from '@tanstack/react-table' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { Outlet, type LoaderFunctionArgs } from 'react-router' -import { api, getListQFn, queryClient, type InternetGateway } from '~/api' +import { api, getListQFn, queryClient, useApiMutation, type InternetGateway } from '~/api' +import { HL } from '~/components/HL' +import { ListPlusOverflow } from '~/components/ListPlusCell' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { EmptyCell } from '~/table/cells/EmptyCell' import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell' import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' import { useQueryTable } from '~/table/QueryTable' import { CopyableIp } from '~/ui/lib/CopyableIp' +import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { TipIcon } from '~/ui/lib/TipIcon' import { ALL_ISH } from '~/util/consts' @@ -43,14 +50,43 @@ const projectIpPoolList = getListQFn(api.ipPoolList, { const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => { const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn()) - if (!addresses || addresses.items.length < 1) return - return + const [first, ...rest] = addresses?.items || [] + if (!first) return + return ( +
+ {/* only the leading address is copyable; the rest live in the +N tooltip */} + + + {rest.map((address) => ( +
{address.address}
+ ))} +
+
+ ) +} + +// plain pool name for the +N tooltip, where IpPoolCell's interactive button +// wouldn't be usable +const IpPoolName = ({ ipPoolId }: { ipPoolId: string }) => { + const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId)) + if (!result || result.type === 'error') return null + return
{result.data.name}
} const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => { - const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn()) - if (!gateways || gateways.items.length < 1) return - return + const { data: pools } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn()) + const [first, ...rest] = pools?.items || [] + if (!first) return + return ( +
+ + + {rest.map((pool) => ( + + ))} + +
+ ) } const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => { @@ -111,12 +147,41 @@ export default function VpcInternetGatewaysTab() { ) - const columns = useMemo( + const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('internetGatewayList') + // prettier-ignore + addToast(<>Internet gateway {variables.path.gateway} deleted) + }, + }) + + const makeActions = useCallback( + (gateway: InternetGateway): MenuAction[] => [ + { + label: 'Delete', + className: 'destructive', + onActivate: confirmDelete({ + doDelete: () => + deleteGateway({ + path: { gateway: gateway.name }, + query: { project, vpc, cascade: true }, + }), + label: gateway.name, + resourceKind: 'internet gateway', + extraContent: + 'Any attached IP pools and IP addresses will be detached, and routes targeting this gateway will be deleted.', + }), + }, + ], + [deleteGateway, project, vpc] + ) + + const staticColumns = useMemo( () => [ colHelper.accessor('name', { cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })), @@ -151,14 +216,32 @@ export default function VpcInternetGatewaysTab() { [project, vpc] ) + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ query: gatewayList({ project, vpc }), columns, emptyState, }) + useQuickActions( + () => [ + { + value: 'New internet gateway', + navGroup: 'Actions', + action: pb.vpcInternetGatewaysNew({ project, vpc }), + }, + ], + [project, vpc] + ) + return ( <> +
+ + New internet gateway + +
{table} diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts index bff27bfce5..99ab700e62 100644 --- a/app/pages/project/vpcs/gateway-data.ts +++ b/app/pages/project/vpcs/gateway-data.ts @@ -35,17 +35,24 @@ export function useGatewayRoutes({ project, vpc, gateway }: PP.VpcInternetGatewa const { data: routers } = usePrefetchedQuery(routerList({ project, vpc }).optionsFn()) const routerNames = routers.items.map((r) => r.name) - const routesQueries = useQueries({ + return useQueries({ queries: routerNames.map((router) => routeList({ project, vpc, router }).optionsFn()), + // combine's result is structurally shared by React Query, so the returned + // array is referentially stable across renders — required by consumers + // that use it as table data or in dep arrays + combine: (results) => { + // loading. should never happen because of prefetches + if (!results.every((q) => !!q.data)) return null + return R.pipe( + R.zip( + routerNames, + results.map((q) => q.data.items) + ), + R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)), + R.filter( + ([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway + ) + ) + }, }) - const loadedRoutesLists = routesQueries.filter((q) => !!q.data).map((q) => q.data.items) - - // loading. should never happen because of prefetches - if (loadedRoutesLists.length < routers.items.length) return null - - return R.pipe( - R.zip(routerNames, loadedRoutesLists), - R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)), - R.filter(([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway) - ) } diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx deleted file mode 100644 index 5d5ac415dd..0000000000 --- a/app/pages/project/vpcs/internet-gateway-edit.tsx +++ /dev/null @@ -1,207 +0,0 @@ -/* - * 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 { useQuery } from '@tanstack/react-query' -import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router' - -import { Gateway16Icon } from '@oxide/design-system/icons/react' - -import { api, q, queryClient, usePrefetchedQuery } from '~/api' -import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm' -import { titleCrumb } from '~/hooks/use-crumbs' -import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' -import { IpPoolCell } from '~/table/cells/IpPoolCell' -import { CopyableIp } from '~/ui/lib/CopyableIp' -import { FormDivider } from '~/ui/lib/Divider' -import { Message } from '~/ui/lib/Message' -import { SideModalFormDocs } from '~/ui/lib/ModalLinks' -import { PropertiesTable } from '~/ui/lib/PropertiesTable' -import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' -import { Table } from '~/ui/lib/Table' -import { docLinks } from '~/util/links' -import { pb } from '~/util/path-builder' -import type * as PP from '~/util/path-params' - -import { - gatewayIpAddressList, - gatewayIpPoolList, - routeList, - routerList, - useGatewayRoutes, -} from './gateway-data' - -export const handle = titleCrumb('Edit Internet Gateway') - -const RoutesEmpty = () => ( - - - No VPC router routes target this gateway. - - -) - -function RouteRows({ project, vpc, gateway }: PP.VpcInternetGateway) { - const matchingRoutes = useGatewayRoutes({ project, vpc, gateway }) - - if (!matchingRoutes) return null - if (matchingRoutes.length === 0) return - - return matchingRoutes.map(([router, route]) => ( - - - - {router} - - - {route.name} - - )) -} - -export async function clientLoader({ params }: LoaderFunctionArgs) { - const { project, vpc, gateway } = getInternetGatewaySelector(params) - await Promise.all([ - queryClient.prefetchQuery( - q(api.internetGatewayView, { - query: { project, vpc }, - path: { gateway }, - }) - ), - queryClient.prefetchQuery(gatewayIpPoolList({ project, vpc, gateway }).optionsFn()), - queryClient.prefetchQuery(gatewayIpAddressList({ project, vpc, gateway }).optionsFn()), - ...(await queryClient.fetchQuery(routerList({ project, vpc }).optionsFn())).items.map( - (router) => - queryClient.prefetchQuery( - routeList({ project, vpc, router: router.name }).optionsFn() - ) - ), - ] satisfies Promise[]) - return null -} - -export default function EditInternetGatewayForm() { - const navigate = useNavigate() - const { project, vpc, gateway } = useInternetGatewaySelector() - const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc })) - const { data: internetGateway } = usePrefetchedQuery( - q(api.internetGatewayView, { - query: { project, vpc }, - path: { gateway }, - }) - ) - const { data: { items: gatewayIpPools } = {} } = useQuery( - gatewayIpPoolList({ project, vpc, gateway }).optionsFn() - ) - const { data: { items: gatewayIpAddresses } = {} } = useQuery( - gatewayIpAddressList({ project, vpc, gateway }).optionsFn() - ) - - const hasAttachedPool = gatewayIpPools && gatewayIpPools.length > 0 - - return ( - - {internetGateway.name} - - } - > - - - {internetGateway.name} - - - - - -
- - Internet gateway IP address - {gatewayIpAddresses && gatewayIpAddresses.length > 1 ? 'es' : ''} - - {gatewayIpAddresses && gatewayIpAddresses.length > 0 ? ( - gatewayIpAddresses.map((gatewayIpAddress) => ( - - - {gatewayIpAddress.name} - - - - - - - )) - ) : ( -
- {'This internet gateway does not have any IP addresses attached. '} - {hasAttachedPool - ? 'It will use an address from the attached IP pool.' - : 'Attach an IP pool or IP address via the CLI or API.'} -
- )} -
- - - -
- - Internet gateway IP pool - {gatewayIpPools && gatewayIpPools.length > 1 ? 's' : ''} - - {hasAttachedPool ? ( - gatewayIpPools.map((gatewayIpPool) => ( - - {gatewayIpPool.name} - - - - - - )) - ) : ( -
- This internet gateway does not have any IP pools attached. -
- )} -
- - - -
- Routes targeting this gateway -
- - - Router - Route - - - - - -
-
- - - - ) -} diff --git a/app/routes.tsx b/app/routes.tsx index 2fdaadc22f..8dff14d7a8 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -485,14 +485,12 @@ export const routes = createRoutesFromElements( /> import('./pages/project/vpcs/VpcGatewaysTab').then(convert)} > + - import('./pages/project/vpcs/internet-gateway-edit').then(convert) - } + path="internet-gateways-new" + lazy={() => import('./forms/internet-gateway-create').then(convert)} /> @@ -518,6 +516,28 @@ export const routes = createRoutesFromElements( + + + import('./pages/project/vpcs/InternetGatewayPage').then(convert) + } + > + + + import('./forms/internet-gateway-ip-pool-create').then(convert) + } + /> + + import('./forms/internet-gateway-ip-address-create').then(convert) + } + /> + + { "vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules", "vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new", "vpcInternetGateway": "/projects/p/vpcs/v/internet-gateways/g", + "vpcInternetGatewayIpAddressesNew": "/projects/p/vpcs/v/internet-gateways/g/ip-addresses-new", + "vpcInternetGatewayIpPoolsNew": "/projects/p/vpcs/v/internet-gateways/g/ip-pools-new", "vpcInternetGateways": "/projects/p/vpcs/v/internet-gateways", + "vpcInternetGatewaysNew": "/projects/p/vpcs/v/internet-gateways-new", "vpcRouter": "/projects/p/vpcs/v/routers/r", "vpcRouterEdit": "/projects/p/vpcs/v/routers/r/edit", "vpcRouterRouteEdit": "/projects/p/vpcs/v/routers/r/routes/rr/edit", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index e09ad45aa7..1d4fe7183a 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -88,10 +88,14 @@ export const pb = { `${pb.vpcSubnets(params)}/${params.subnet}/edit`, vpcInternetGateways: (params: PP.Vpc) => `${vpcBase(params)}/internet-gateways`, + vpcInternetGatewaysNew: (params: PP.Vpc) => `${vpcBase(params)}/internet-gateways-new`, vpcInternetGateway: (params: PP.VpcInternetGateway) => `${pb.vpcInternetGateways(params)}/${params.gateway}`, - // vpcInternetGatewaysNew: (params: Vpc) => `${vpcBase(params)}/internet-gateways-new`, - // + vpcInternetGatewayIpPoolsNew: (params: PP.VpcInternetGateway) => + `${pb.vpcInternetGateway(params)}/ip-pools-new`, + vpcInternetGatewayIpAddressesNew: (params: PP.VpcInternetGateway) => + `${pb.vpcInternetGateway(params)}/ip-addresses-new`, + externalSubnets: (params: PP.Project) => `${projectBase(params)}/external-subnets`, externalSubnetsNew: (params: PP.Project) => `${projectBase(params)}/external-subnets-new`, externalSubnetEdit: (params: PP.ExternalSubnet) => diff --git a/mock-api/internet-gateway.ts b/mock-api/internet-gateway.ts index 93c1cfeb10..bc9dc6c89a 100644 --- a/mock-api/internet-gateway.ts +++ b/mock-api/internet-gateway.ts @@ -70,7 +70,7 @@ export const internetGatewayIpAddresses: Json[] = [ ] const internetGatewayIpPool1: Json = { - id: '1d5e5a1f-0b2b-4d5b-8b9d-2d4b3e0c6gb9', + id: '48bab245-98a4-402e-8689-13af5f7d5411', name: 'internet-gateway-pool-1', description: 'An IP pool for an internet gateway', internet_gateway_id: internetGateway1.id, @@ -81,7 +81,7 @@ const internetGatewayIpPool1: Json = { const internetGatewayIpPool2: Json = { id: 'd5e5a1f1-0b2b-4d5b-8b9d-2d4b3e0c6b9c', - name: 'interent-gateway-pool-2', + name: 'internet-gateway-pool-2', description: 'another IP pool for an internet gateway', internet_gateway_id: internetGateway2.id, ip_pool_id: ipPool2.id, diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 9986205ed2..30eb216826 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -366,6 +366,25 @@ export const lookup = { return ip }, + internetGatewayIpPool({ + pool: id, + ...gatewaySelector + }: Sel.InternetGatewayIpPool): Json { + if (!id) throw notFoundErr('no IP pool specified') + + if (isUuid(id)) { + ensureNoParentSelectors('IP pool', gatewaySelector) + return lookupById(db.internetGatewayIpPools, id) + } + + const gateway = lookup.internetGateway(gatewaySelector) + const pool = db.internetGatewayIpPools.find( + (p) => p.internet_gateway_id === gateway.id && p.name === id + ) + if (!pool) throw notFoundErr(`IP pool '${id}'`) + + return pool + }, image({ image: id, project: projectId }: Sel.Image): Json { if (!id) throw notFoundErr('no image specified') diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 5f2b056373..66df550f47 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1632,6 +1632,98 @@ export const handlers = makeHandlers({ return paginated(query, gateways) }, internetGatewayView: ({ path, query }) => lookup.internetGateway({ ...path, ...query }), + internetGatewayCreate({ body, query }) { + const vpc = lookup.vpc(query) + errIfExists(db.internetGateways, { vpc_id: vpc.id, name: body.name }) + + const newGateway: Json = { + id: uuid(), + vpc_id: vpc.id, + ...body, + ...getTimestamps(), + } + db.internetGateways.push(newGateway) + return json(newGateway, { status: 201 }) + }, + internetGatewayDelete({ path, query }) { + const gateway = lookup.internetGateway({ ...path, ...query }) + + const hasPools = db.internetGatewayIpPools.some( + (p) => p.internet_gateway_id === gateway.id + ) + const hasAddresses = db.internetGatewayIpAddresses.some( + (a) => a.internet_gateway_id === gateway.id + ) + // without cascade, deletion fails if any IP pools or addresses are attached + // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L1645-L1711 + if (!query.cascade && (hasPools || hasAddresses)) { + const attached = [hasPools && 'IP pools', hasAddresses && 'IP addresses'] + .filter(Boolean) + .join(' and ') + throw invalidRequest( + `${attached} referencing this gateway exist. To perform a cascading delete set the cascade option` + ) + } + + db.internetGateways = db.internetGateways.filter((g) => g.id !== gateway.id) + db.internetGatewayIpPools = db.internetGatewayIpPools.filter( + (p) => p.internet_gateway_id !== gateway.id + ) + db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter( + (a) => a.internet_gateway_id !== gateway.id + ) + db.vpcRouterRoutes = db.vpcRouterRoutes.filter( + (r) => !(r.target.type === 'internet_gateway' && r.target.value === gateway.name) + ) + return 204 + }, + internetGatewayIpPoolCreate({ body, query }) { + const gateway = lookup.internetGateway(query) + const ipPool = lookup.ipPool({ pool: body.ip_pool }) + errIfExists(db.internetGatewayIpPools, { + internet_gateway_id: gateway.id, + name: body.name, + }) + + const newGatewayIpPool: Json = { + id: uuid(), + internet_gateway_id: gateway.id, + ip_pool_id: ipPool.id, + name: body.name, + description: body.description, + ...getTimestamps(), + } + db.internetGatewayIpPools.push(newGatewayIpPool) + return json(newGatewayIpPool, { status: 201 }) + }, + internetGatewayIpPoolDelete({ path, query }) { + const pool = lookup.internetGatewayIpPool({ ...path, ...query }) + db.internetGatewayIpPools = db.internetGatewayIpPools.filter((p) => p.id !== pool.id) + return 204 + }, + internetGatewayIpAddressCreate({ body, query }) { + const gateway = lookup.internetGateway(query) + errIfExists(db.internetGatewayIpAddresses, { + internet_gateway_id: gateway.id, + name: body.name, + }) + + const newGatewayIpAddress: Json = { + id: uuid(), + internet_gateway_id: gateway.id, + ...body, + ...getTimestamps(), + } + db.internetGatewayIpAddresses.push(newGatewayIpAddress) + return json(newGatewayIpAddress, { status: 201 }) + }, + internetGatewayIpAddressDelete({ path, query }) { + const address = lookup.internetGatewayIpAddress({ ...path, ...query }) + db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter( + (a) => a.id !== address.id + ) + return 204 + }, internetGatewayIpPoolList({ query }) { const gateway = lookup.internetGateway(query) const pools = db.internetGatewayIpPools.filter( @@ -2651,12 +2743,6 @@ export const handlers = makeHandlers({ instanceSerialConsole: NotImplemented, instanceSerialConsoleStream: NotImplemented, instanceSshPublicKeyList: NotImplemented, - internetGatewayCreate: NotImplemented, - internetGatewayDelete: NotImplemented, - internetGatewayIpAddressCreate: NotImplemented, - internetGatewayIpAddressDelete: NotImplemented, - internetGatewayIpPoolCreate: NotImplemented, - internetGatewayIpPoolDelete: NotImplemented, systemIpPoolServiceRangeAdd: NotImplemented, systemIpPoolServiceRangeList: NotImplemented, systemIpPoolServiceRangeRemove: NotImplemented, diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 6df00b31ba..4c2c171e6a 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -7,7 +7,13 @@ */ import { expect, test } from '@playwright/test' -import { clickRowAction, expectRowVisible, getPageAsUser, selectOption } from './utils' +import { + clickRowAction, + expectRowVisible, + expectToast, + getPageAsUser, + selectOption, +} from './utils' test('can nav to VpcPage from /', async ({ page }) => { await page.goto('/') @@ -353,17 +359,109 @@ test('can view internet gateways', async ({ page }) => { await expect(page).toHaveURL( '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1' ) - // Use getByRole instead of getByLabel to avoid matching truncated descriptions - const sidemodal = page.getByRole('dialog', { name: 'Internet gateway' }) + await expect(page.getByRole('heading', { name: 'internet-gateway-1' })).toBeVisible() + + const poolsTable = page + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) }) + await expectRowVisible(poolsTable, { + name: 'internet-gateway-pool-1', + 'IP pool': 'ip-pool-1', + }) - await expect(sidemodal.getByText('123.4.56.3')).toBeVisible() + const addressesTable = page + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'address' }) }) + await expectRowVisible(addressesTable, { + name: 'internet-gateway-address-1', + address: '123.4.56.3', + }) - // close the sidemodal - await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() - await expect(sidemodal).toBeHidden() + // gateway 2 has no addresses attached + await page.getByRole('link', { name: 'Internet Gateways' }).click() + await page.getByRole('link', { name: 'internet-gateway-2' }).click() + await expect(page.getByRole('heading', { name: 'internet-gateway-2' })).toBeVisible() + await expect(page.getByText('No IP addresses attached')).toBeVisible() +}) +test('can create and delete an internet gateway', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs/mock-vpc/internet-gateways') + + await page.getByRole('link', { name: 'New internet gateway' }).click() + const dialog = page.getByRole('dialog', { name: 'Create internet gateway' }) + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('new-gateway') + await dialog.getByRole('textbox', { name: 'Description' }).fill('a new gateway') + await dialog.getByRole('button', { name: 'Create internet gateway' }).click() + await expectToast(page, 'Internet gateway new-gateway created') + + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'new-gateway', description: 'a new gateway' }) + await expect(table.locator('tbody >> tr')).toHaveCount(3) + + await clickRowAction(page, 'new-gateway', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Internet gateway new-gateway deleted') + await expect(table.locator('tbody >> tr')).toHaveCount(2) +}) + +test('can attach and detach IP pools and addresses on an internet gateway', async ({ + page, +}) => { + await page.goto( + '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-2' + ) + await expect(page.getByText('No IP addresses attached')).toBeVisible() + + // attach an IP address + await page.getByRole('link', { name: 'Attach IP address' }).first().click() + const addressDialog = page.getByRole('dialog', { name: 'Attach IP address' }) + await addressDialog.getByRole('textbox', { name: 'Name' }).fill('my-address') + await addressDialog.getByRole('textbox', { name: 'Description' }).fill('an address') + await addressDialog.getByRole('textbox', { name: 'IP address' }).fill('123.4.56.7') + await addressDialog.getByRole('button', { name: 'Attach IP address' }).click() + await expectToast(page, 'IP address my-address attached') + + const addressesTable = page + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'address' }) }) + await expectRowVisible(addressesTable, { name: 'my-address', address: '123.4.56.7' }) + + // detach it again + await clickRowAction(page, 'my-address', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'IP address my-address detached') + await expect(page.getByText('No IP addresses attached')).toBeVisible() + + // attach another IP pool + await page.getByRole('link', { name: 'Attach IP pool' }).first().click() + const poolDialog = page.getByRole('dialog', { name: 'Attach IP pool' }) + await poolDialog.getByRole('textbox', { name: 'Name' }).fill('my-pool') + await poolDialog.getByRole('textbox', { name: 'Description' }).fill('a pool') + await poolDialog.getByLabel('IP pool').click() + await page.getByRole('option', { name: 'ip-pool-1' }).click() + await poolDialog.getByRole('button', { name: 'Attach IP pool' }).click() + await expectToast(page, 'IP pool my-pool attached') + + const poolsTable = page + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) }) + await expectRowVisible(poolsTable, { name: 'my-pool', 'IP pool': 'ip-pool-1' }) + + // the list view shows the first pool plus a +1 overflow for the second + await page.getByRole('link', { name: 'Internet Gateways' }).click() + await expectRowVisible(page.getByRole('table'), { + name: 'internet-gateway-2', + 'Attached IP Pool': expect.stringContaining('+1'), + }) + + // back to the detail page to detach the pre-existing pool await page.getByRole('link', { name: 'internet-gateway-2' }).click() - await expect(sidemodal.getByText('This internet gateway does not have any')).toBeVisible() + + // detach the pool that was already attached in the mock data + await clickRowAction(page, 'internet-gateway-pool-2', 'Detach') + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'IP pool internet-gateway-pool-2 detached') + await expect(poolsTable.locator('tbody >> tr')).toHaveCount(1) }) test('internet gateway shows proper list of routes targeting it', async ({ page }) => { @@ -371,16 +469,15 @@ test('internet gateway shows proper list of routes targeting it', async ({ page await page.goto( '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1' ) - // verify that it has a table with the row showing "mock-custom-router" and "dc2" - const sidemodal = page.getByRole('dialog', { name: 'Internet Gateway' }) - const table = sidemodal.getByRole('table') - await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' }) - await expect(table.locator('tbody >> tr')).toHaveCount(1) - - // close the sidemodal - await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click() - await expect(sidemodal).toBeHidden() - // check for the route count; which should be 1 + // verify that the routes table has a row showing "mock-custom-router" and "dc2" + const routesTable = page + .getByRole('table') + .filter({ has: page.getByRole('columnheader', { name: 'Router' }) }) + await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' }) + await expect(routesTable.locator('tbody >> tr')).toHaveCount(1) + + // go back to the gateways list and check the route count, which should be 1 + await page.getByRole('link', { name: 'Internet Gateways' }).click() await expect(page.getByRole('link', { name: '1', exact: true })).toBeVisible() // go to the Routers tab await page.getByRole('tab', { name: 'Routers' }).click() @@ -391,7 +488,6 @@ test('internet gateway shows proper list of routes targeting it', async ({ page '/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router' ) - await page.getByRole('link', { name: 'mock-custom-router' }).click() // create a new route await page.getByRole('link', { name: 'New route' }).click() await page.getByRole('textbox', { name: 'Name' }).fill('new-route') @@ -404,16 +500,16 @@ test('internet gateway shows proper list of routes targeting it', async ({ page await page.getByRole('link', { name: 'mock-vpc' }).click() // click on the internet gateways tab and then the internet-gateway-1 link to go to the detail page await page.getByRole('tab', { name: 'Internet Gateways' }).click() - // verify that the route count is now 2: click on the link to go to the edit gateway sidemodal + // verify that the route count is now 2: click on the link to go to the gateway detail page await page.getByRole('link', { name: '2', exact: true }).click() // the new route should be visible in the table - await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' }) - await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'new-route' }) - await expect(table.locator('tbody >> tr')).toHaveCount(2) + await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' }) + await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'new-route' }) + await expect(routesTable.locator('tbody >> tr')).toHaveCount(2) - // click on the new-route link to go to the detail page - await sidemodal.getByRole('link', { name: 'mock-custom-router' }).first().click() + // click on the router link to go to the router detail page + await routesTable.getByRole('link', { name: 'mock-custom-router' }).first().click() // expect to be on the view page await expect(page).toHaveURL( '/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router'