From ada302ce90294b8957dbda923255de3e98db1c01 Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Fri, 13 Dec 2024 09:27:38 -0800 Subject: [PATCH] Add read-only UI for internet gateways (#2488) * Update OMICRON_VERSION * update msw handlers * Other changes from initial branch * IP Pools tab in place * IP Pool ID is copyable from IP Pools tab * Add content to IP Addresses tab and root page * Update OMICRON_VERSION to sha with internet gateways * small tweaks * remove commented-out code * Use proper truncate component * adjust path-builder spec * Add breadcrumb nav for internet gateways * Update default internet gateway IP pool to reflect actual default values * update unrelated test * Remove n+1 query on IpPools * Add IpPoolCell * Remove code that we'll add separately * Upgrade OMICRON_VERSION * No need to extract UtilizationCell now * Get IP Pool tab for Internet Gateways working * Update snapshot, but there might still be some other issues to work out * Some headway, but screen is still blank * Update snapshots for test * Update routes; fix mock data * update tests with mock data, but this should probably get pulled to a new branch * Simplify mock data; renaming the default IP Pool to default was unnecessary and confusing * convert to new useQueryTable * Add internet gateway combobox to router route target field * Sidebar for Internet Gateway coming together * Bot commit: format with prettier * DOM shuffling * Update mock data * Update routes to handle new sidebar and main tab together * Reorder internet gateway sidebar * Update paths and snapshots * use more common internet-gateway-edit syntax for filename * Internet gateways modal tweak (#2607) Internet gateway modal tweaks * Add IP Address and IP Pool columns to Gateway table * test update * small tweaks to sidebar * reverting back to vertical table for now, to render IP Pool alonside join table info * Update copy when missing pool or ip address * Add a test for internet gateway list and sidemodal * Add routes targeting gateway to table * Better handle multiple route spacing; fix test * Update side modal with routes targeting gateway * Update test for showing route * Tweaks to sidemodal * move example gateway route to custom router, not default router * Update tests to reflect gateway route existing on custom router * use more specific params in queries * use titleCrumb for Edit Internet Gateway * fix RR leaf route without element warning * let's use a valid UUID * clean up InternetGatewayRoutes and call it as a component * update the snapshot! * fix gnarly dependent promises in gateways loader * update read only info box * clean up gateway routes fetch logic by extracting shared hook * extract gateway data logic into a separate file * Use count of routes; link to sidemodal * Use count of 0 instead of EmptyCell for route count * use EmptyCell for zero routes, copy tweaks, sentence case * sentence case idp form heading * minor: remove stub e2e test --------- Co-authored-by: David Crespo Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Benjamin Leonard --- app/api/selectors.ts | 5 + app/forms/firewall-rules-common.tsx | 4 +- app/forms/vpc-router-route-common.tsx | 57 +++-- app/forms/vpc-router-route-create.tsx | 8 +- app/forms/vpc-router-route-edit.tsx | 9 +- app/hooks/use-params.ts | 3 + app/pages/project/vpcs/VpcPage/VpcPage.tsx | 1 + .../vpcs/VpcPage/tabs/VpcGatewaysTab.tsx | 151 ++++++++++++ app/pages/project/vpcs/gateway-data.ts | 49 ++++ .../project/vpcs/internet-gateway-edit.tsx | 222 ++++++++++++++++++ app/routes.tsx | 22 +- .../__snapshots__/path-builder.spec.ts.snap | 44 ++++ app/util/links.ts | 2 + app/util/path-builder.spec.ts | 3 + app/util/path-builder.ts | 6 + app/util/path-params.ts | 1 + mock-api/index.ts | 1 + mock-api/internet-gateway.ts | 95 ++++++++ mock-api/ip-pool.ts | 6 +- mock-api/msw/db.ts | 45 +++- mock-api/msw/handlers.ts | 24 +- mock-api/vpc.ts | 16 ++ test/e2e/vpcs.e2e.ts | 101 +++++++- 23 files changed, 832 insertions(+), 43 deletions(-) create mode 100644 app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx create mode 100644 app/pages/project/vpcs/gateway-data.ts create mode 100644 app/pages/project/vpcs/internet-gateway-edit.tsx create mode 100644 mock-api/internet-gateway.ts diff --git a/app/api/selectors.ts b/app/api/selectors.ts index 47275576bb..fa7db394c6 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -16,6 +16,11 @@ export type NetworkInterface = Readonly> export type Snapshot = Readonly> export type Vpc = Readonly> export type VpcRouter = Readonly> +export type InternetGateway = Readonly> +export type InternetGatewayIpAddress = Readonly< + Merge +> +export type InternetGatewayIpPool = Merge export type VpcRouterRoute = Readonly> export type VpcSubnet = Readonly> export type FirewallRule = Readonly> diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 0f6f200598..809799b722 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -323,9 +323,9 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) = target="_blank" rel="noreferrer" > - guest networking guide + Networking {' '} - and{' '} + guide and the{' '} @@ -30,8 +31,6 @@ export type RouteFormValues = RouterRouteCreate | Required export const routeFormMessage = { vpcSubnetNotModifiable: 'Routes of type VPC Subnet within the system router are not modifiable', - internetGatewayTargetValue: - 'For ‘Internet gateway’ targets, the value must be ‘outbound’', // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204 noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router', // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304 @@ -75,7 +74,7 @@ const destinationValueDescription: Record = { ip: 'Enter an IP', instance: 'Select an instance', - internet_gateway: undefined, + internet_gateway: 'Select an internet gateway', drop: undefined, subnet: undefined, vpc: undefined, @@ -84,7 +83,7 @@ const targetValuePlaceholder: Record = const targetValueDescription: Record = { ip: 'An IP address, like 10.0.1.5', instance: undefined, - internet_gateway: routeFormMessage.internetGatewayTargetValue, + internet_gateway: undefined, drop: undefined, subnet: undefined, vpc: undefined, @@ -103,10 +102,15 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => { // usePrefetchedApiQuery items below are initially fetched in the loaders in vpc-router-route-create and -edit const { data: { items: vpcSubnets }, - } = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: 1000 } }) + } = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } }) const { data: { items: instances }, - } = usePrefetchedApiQuery('instanceList', { query: { project, limit: 1000 } }) + } = usePrefetchedApiQuery('instanceList', { query: { project, limit: ALL_ISH } }) + const { + data: { items: internetGateways }, + } = usePrefetchedApiQuery('internetGatewayList', { + query: { project, vpc, limit: ALL_ISH }, + }) const { control } = form const destinationType = form.watch('destination.type') @@ -129,13 +133,35 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => { control, placeholder: targetValuePlaceholder[targetType], required: true, - // 'internet_gateway' targetTypes can only have the value 'outbound', so we disable the field - disabled: disabled || targetType === 'internet_gateway', + disabled, description: targetValueDescription[targetType], // need a default to prevent the text field validation function from // sticking around when we switch to the combobox validate: () => undefined, } + + const targetTypeField = () => { + if (targetType === 'drop') { + return null + } + if (targetType === 'instance') { + return + } + if (targetType === 'internet_gateway') { + return ( + + ) + } + return ( + + (target.type === 'ip' && validateIp(value)) || undefined + } + /> + ) + } + return ( <> {disabled && ( @@ -176,22 +202,13 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => { items={toListboxItems(targetTypes)} placeholder="Select a target type" required - onChange={(value) => { - form.setValue('target.value', value === 'internet_gateway' ? 'outbound' : '') + onChange={() => { + form.setValue('target.value', '') form.clearErrors('target.value') }} disabled={disabled} /> - {targetType === 'drop' ? null : targetType === 'instance' ? ( - - ) : ( - - (target.type === 'ip' && validateIp(value)) || undefined - } - /> - )} + {targetTypeField()} ) } diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 8030b55dcd..3f89dc8f7c 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -15,6 +15,7 @@ import { HL } from '~/components/HL' import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' const defaultValues: RouteFormValues = { @@ -28,10 +29,13 @@ CreateRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) = const { project, vpc } = getVpcRouterSelector(params) await Promise.all([ apiQueryClient.prefetchQuery('vpcSubnetList', { - query: { project, vpc, limit: 1000 }, + query: { project, vpc, limit: ALL_ISH }, }), apiQueryClient.prefetchQuery('instanceList', { - query: { project, limit: 1000 }, + query: { project, limit: ALL_ISH }, + }), + apiQueryClient.prefetchQuery('internetGatewayList', { + query: { project, vpc, limit: ALL_ISH }, }), ]) return null diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index da1c06338e..78a94f066c 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -25,6 +25,7 @@ import { } from '~/forms/vpc-router-route-common' import { getVpcRouterRouteSelector, useVpcRouterRouteSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' +import { ALL_ISH } from '~/util/consts' import { pb } from '~/util/path-builder' EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -35,10 +36,13 @@ EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => query: { project, vpc, router }, }), apiQueryClient.prefetchQuery('vpcSubnetList', { - query: { project, vpc, limit: 1000 }, + query: { project, vpc, limit: ALL_ISH }, }), apiQueryClient.prefetchQuery('instanceList', { - query: { project, limit: 1000 }, + query: { project, limit: ALL_ISH }, + }), + apiQueryClient.prefetchQuery('internetGatewayList', { + query: { project, vpc, limit: ALL_ISH }, }), ]) return null @@ -65,6 +69,7 @@ export function EditRouterRouteSideModalForm() { const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { onSuccess(updatedRoute) { queryClient.invalidateQueries('vpcRouterRouteList') + queryClient.invalidateQueries('vpcRouterRouteView') addToast(<>Route {updatedRoute.name} updated) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 7bee58f446..e9bea342c3 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -40,6 +40,7 @@ export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule') export const getVpcRouterSelector = requireParams('project', 'vpc', 'router') export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route') export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet') +export const getInternetGatewaySelector = requireParams('project', 'vpc', 'gateway') export const getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') export const getSshKeySelector = requireParams('sshKey') @@ -86,6 +87,8 @@ export const useVpcSelector = () => useSelectedParams(getVpcSelector) export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector) export const useVpcRouterRouteSelector = () => useSelectedParams(getVpcRouterRouteSelector) export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector) +export const useInternetGatewaySelector = () => + useSelectedParams(getInternetGatewaySelector) export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index dfb6a0f041..87993db96c 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -98,6 +98,7 @@ export function VpcPage() { Firewall Rules Subnets Routers + Internet Gateways ) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx new file mode 100644 index 0000000000..0fd50179df --- /dev/null +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx @@ -0,0 +1,151 @@ +/* + * 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 { createColumnHelper } from '@tanstack/react-table' +import { useMemo } from 'react' +import { Outlet, type LoaderFunctionArgs } from 'react-router-dom' + +import { apiq, getListQFn, queryClient, type InternetGateway } from '~/api' +import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' +import { EmptyCell } from '~/table/cells/EmptyCell' +import { IpPoolCell } from '~/table/cells/IpPoolCell' +import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CopyableIp } from '~/ui/lib/CopyableIp' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +import { + gatewayIpAddressList, + gatewayIpPoolList, + routeList, + routerList, + useGatewayRoutes, +} from '../../gateway-data' + +const gatewayList = ({ project, vpc }: PP.Vpc) => + getListQFn('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } }) +const projectIpPoolList = getListQFn('projectIpPoolList', { query: { limit: ALL_ISH } }) + +const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => { + const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn()) + if (!addresses || addresses.items.length < 1) return + return +} + +const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => { + const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn()) + if (!gateways || gateways.items.length < 1) return + return +} + +const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => { + const matchingRoutes = useGatewayRoutes({ project, vpc, gateway }) + const to = pb.vpcInternetGateway({ project, vpc, gateway }) + if (!matchingRoutes?.length) return + return {matchingRoutes.length} +} + +const colHelper = createColumnHelper() + +VpcInternetGatewaysTab.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, vpc } = getVpcSelector(params) + const [gateways, routers] = await Promise.all([ + queryClient.fetchQuery(gatewayList({ project, vpc }).optionsFn()), + queryClient.fetchQuery(routerList({ project, vpc }).optionsFn()), + ]) + + await Promise.all([ + ...gateways.items.flatMap((gateway: InternetGateway) => [ + queryClient.prefetchQuery( + gatewayIpAddressList({ project, vpc, gateway: gateway.name }).optionsFn() + ), + queryClient.prefetchQuery( + gatewayIpPoolList({ project, vpc, gateway: gateway.name }).optionsFn() + ), + ]), + ...routers.items.map((router) => + queryClient.prefetchQuery( + routeList({ project, vpc, router: router.name }).optionsFn() + ) + ), + queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => { + for (const pool of pools.items) { + const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } }) + queryClient.setQueryData(queryKey, pool) + } + }), + ] satisfies Promise[]) + + return null +} + +export function VpcInternetGatewaysTab() { + const { project, vpc } = useVpcSelector() + + const emptyState = ( + + ) + + const columns = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('name', { + // ID needed to avoid key collision with other name column + id: 'ip-address', + header: 'Attached IP Address', + cell: (info) => ( + + ), + }), + colHelper.accessor('name', { + // ID needed to avoid key collision with other name column + id: 'ip-pool', + header: 'Attached IP Pool', + cell: (info) => ( + + ), + }), + colHelper.accessor('name', { + // ID needed to avoid key collision with other name column + id: 'routes', + header: 'Routes', + cell: (info) => ( + + ), + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [project, vpc] + ) + + const { table } = useQueryTable({ + query: gatewayList({ project, vpc }), + columns, + emptyState, + }) + + return ( + <> + {table} + + + ) +} diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts new file mode 100644 index 0000000000..d7047ae6f7 --- /dev/null +++ b/app/pages/project/vpcs/gateway-data.ts @@ -0,0 +1,49 @@ +/* + * 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 { useQueries } from '@tanstack/react-query' +import * as R from 'remeda' + +import { getListQFn, usePrefetchedQuery } from '~/api' +import { ALL_ISH } from '~/util/consts' +import type * as PP from '~/util/path-params' + +export const routerList = ({ project, vpc }: PP.Vpc) => + getListQFn('vpcRouterList', { query: { project, vpc, limit: ALL_ISH } }) +export const routeList = ({ project, vpc, router }: PP.VpcRouter) => + getListQFn('vpcRouterRouteList', { query: { project, vpc, router, limit: ALL_ISH } }) +export const gatewayIpPoolList = ({ project, vpc, gateway }: PP.VpcInternetGateway) => + getListQFn('internetGatewayIpPoolList', { + query: { project, vpc, gateway, limit: ALL_ISH }, + }) +export const gatewayIpAddressList = ({ project, vpc, gateway }: PP.VpcInternetGateway) => + getListQFn('internetGatewayIpAddressList', { + query: { project, vpc, gateway, limit: ALL_ISH }, + }) + +/** + * For a given gateway, return a list of [router name, RouterRoute] pairs + */ +export function useGatewayRoutes({ project, vpc, gateway }: PP.VpcInternetGateway) { + const { data: routers } = usePrefetchedQuery(routerList({ project, vpc }).optionsFn()) + const routerNames = routers.items.map((r) => r.name) + + const routesQueries = useQueries({ + queries: routerNames.map((router) => routeList({ project, vpc, router }).optionsFn()), + }) + 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 new file mode 100644 index 0000000000..0301fe869f --- /dev/null +++ b/app/pages/project/vpcs/internet-gateway-edit.tsx @@ -0,0 +1,222 @@ +/* + * 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 { useForm } from 'react-hook-form' +import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' + +import { Gateway16Icon } from '@oxide/design-system/icons/react' + +import { apiQueryClient, queryClient, usePrefetchedApiQuery } from '~/api' +import { SideModalForm } from '~/components/form/SideModalForm' +import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params' +import { DescriptionCell } from '~/table/cells/DescriptionCell' +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 { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { ResourceLabel, SideModal } from '~/ui/lib/SideModal' +import { Table } from '~/ui/lib/Table' +import { links } 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' + +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} + + + + )) +} + +EditInternetGatewayForm.loader = async function ({ params }: LoaderFunctionArgs) { + const { project, vpc, gateway } = getInternetGatewaySelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('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 function EditInternetGatewayForm() { + const navigate = useNavigate() + const { project, vpc, gateway } = useInternetGatewaySelector() + const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc })) + const { data: internetGateway } = usePrefetchedApiQuery('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 form = useForm({}) + + const hasAttachedPool = gatewayIpPools && gatewayIpPools.length > 0 + + return ( + + {internetGateway.name} + + } + form={form} + // TODO: pass actual error when this form is hooked up + submitError={null} + loading={false} + > + + For now, gateways can only be modified through the API. Learn more in the{' '} + + Networking + {' '} + guide. + + } + /> + + {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.' + : 'Use the CLI to attach an IP pool or IP address to this gateway.'} +
+ )} +
+ + + +
+ + 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 + {gatewayIpPools && gatewayIpPools.length > 1 ? 's' : ''} + + + + + Router + Route + + + + + +
+
+ + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 19e8e671a1..95915564b7 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -65,8 +65,10 @@ import * as NetworkingTab from './pages/project/instances/instance/tabs/Networki import * as StorageTab from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage' +import { EditInternetGatewayForm } from './pages/project/vpcs/internet-gateway-edit' import * as RouterPage from './pages/project/vpcs/RouterPage' import { VpcFirewallRulesTab } from './pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab' +import { VpcInternetGatewaysTab } from './pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab' import * as VpcRoutersTab from './pages/project/vpcs/VpcPage/tabs/VpcRoutersTab' import * as VpcSubnetsTab from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab' import { VpcPage } from './pages/project/vpcs/VpcPage/VpcPage' @@ -296,7 +298,6 @@ export const routes = createRoutesFromElements( - pb.vpcs(getProjectSelector(p)))} @@ -309,7 +310,6 @@ export const routes = createRoutesFromElements( handle={titleCrumb('New VPC')} /> - + } + > + } + loader={EditInternetGatewayForm.loader} + handle={titleCrumb('Edit Internet Gateway')} + /> + @@ -391,7 +404,7 @@ export const routes = createRoutesFromElements( p.router!)}> - + } @@ -427,7 +440,6 @@ export const routes = createRoutesFromElements( handle={titleCrumb('Edit Floating IP')} /> - } handle={makeCrumb('Disks', (p) => pb.disks(getProjectSelector(p)))} @@ -444,7 +456,6 @@ export const routes = createRoutesFromElements( handle={titleCrumb('New disk')} /> - } handle={makeCrumb('Snapshots', (p) => pb.snapshots(getProjectSelector(p)))} @@ -463,7 +474,6 @@ export const routes = createRoutesFromElements( handle={titleCrumb('Create image from snapshot')} /> - } handle={makeCrumb('Images', (p) => pb.projectImages(getProjectSelector(p)))} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index debb608d86..7142b88c84 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -719,6 +719,50 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/vpcs/v/", }, ], + "vpcInternetGateway (/projects/p/vpcs/v/internet-gateways/g)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "VPCs", + "path": "/projects/p/vpcs", + }, + { + "label": "v", + "path": "/projects/p/vpcs/v/firewall-rules", + }, + { + "label": "Internet Gateways", + "path": "/projects/p/vpcs/v/internet-gateways", + }, + ], + "vpcInternetGateways (/projects/p/vpcs/v/internet-gateways)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "VPCs", + "path": "/projects/p/vpcs", + }, + { + "label": "v", + "path": "/projects/p/vpcs/v/firewall-rules", + }, + { + "label": "Internet Gateways", + "path": "/projects/p/vpcs/v/internet-gateways", + }, + ], "vpcRouter (/projects/p/vpcs/v/routers/r)": [ { "label": "Projects", diff --git a/app/util/links.ts b/app/util/links.ts index ef5a5aec4f..b3bc96256f 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -16,6 +16,8 @@ export const links = { firewallRulesDocs: 'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules', floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips', + gatewaysDocs: + 'https://docs.oxide.computer/guides/configuring-guest-networking#internet-gateway', imagesDocs: 'https://docs.oxide.computer/guides/creating-and-sharing-images', preparingImagesDocs: 'https://docs.oxide.computer/guides/creating-and-sharing-images#_preparing_images_for_import', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 291447a1ba..4464c4bd7a 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -17,6 +17,7 @@ import { pb } from './path-builder' // params can be the same for all of them because they only use what they need const params = { floatingIp: 'f', + gateway: 'g', project: 'p', instance: 'i', vpc: 'v', @@ -93,6 +94,8 @@ test('path builder', () => { "vpcFirewallRuleEdit": "/projects/p/vpcs/v/firewall-rules/fr/edit", "vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules", "vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new", + "vpcInternetGateway": "/projects/p/vpcs/v/internet-gateways/g", + "vpcInternetGateways": "/projects/p/vpcs/v/internet-gateways", "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 f2ec29204a..5b5ab823f9 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -78,6 +78,12 @@ export const pb = { vpcSubnetsNew: (params: PP.Vpc) => `${vpcBase(params)}/subnets-new`, vpcSubnetsEdit: (params: PP.VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`, + + vpcInternetGateways: (params: PP.Vpc) => `${vpcBase(params)}/internet-gateways`, + vpcInternetGateway: (params: PP.VpcInternetGateway) => + `${pb.vpcInternetGateways(params)}/${params.gateway}`, + // vpcInternetGatewaysNew: (params: Vpc) => `${vpcBase(params)}/internet-gateways-new`, + // 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 88e27f58bf..38f3d2616c 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -24,4 +24,5 @@ export type FirewallRule = Required export type VpcRouter = Required export type VpcRouterRoute = Required export type VpcSubnet = Required +export type VpcInternetGateway = Required export type SshKey = Required diff --git a/mock-api/index.ts b/mock-api/index.ts index e033111450..0256808bf2 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -11,6 +11,7 @@ export * from './external-ip' export * from './floating-ip' export * from './image' export * from './instance' +export * from './internet-gateway' export * from './ip-pool' export * from './network-interface' export * from './physical-disk' diff --git a/mock-api/internet-gateway.ts b/mock-api/internet-gateway.ts new file mode 100644 index 0000000000..93c1cfeb10 --- /dev/null +++ b/mock-api/internet-gateway.ts @@ -0,0 +1,95 @@ +/* + * 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 { + InternetGateway, + InternetGatewayIpAddress, + InternetGatewayIpPool, +} from '@oxide/api' + +import { ipPool1, ipPool2 } from './ip-pool' +import type { Json } from './json-type' +import { vpc, vpc2 } from './vpc' + +const time_created = new Date(2021, 0, 1).toISOString() +const time_modified = new Date(2021, 0, 2).toISOString() + +// An internet gateway for VPC 1 +const internetGateway1: Json = { + id: 'f1d5e5a1-0b2b-4d5b-8b9d-2d4b3e0c6b9a', + name: 'internet-gateway-1', + description: 'internet gateway 1', + vpc_id: vpc.id, + time_created, + time_modified, +} + +// Another internet gateway for VPC 1 +const internetGateway2: Json = { + id: 'f1d5e5a1-0b2b-4d5b-8b9d-2d4b3e0c6b9b', + name: 'internet-gateway-2', + description: 'internet gateway 2', + vpc_id: vpc.id, + time_created, + time_modified, +} + +// An internet gateway for VPC 2 +const internetGateway3: Json = { + id: 'f1d5e5a1-0b2b-4d5b-8b9d-2d4b3e0c6b9c', + name: 'internet-gateway-3', + description: 'internet gateway 3', + vpc_id: vpc2.id, + time_created, + time_modified, +} + +export const internetGateways: Json[] = [ + internetGateway1, + internetGateway2, + internetGateway3, +] + +const internetGatewayIpAddress1: Json = { + id: 'f1d5e5a1-0b2b-4d5b-8b9d-2d4b3e0c6b9d', + name: 'internet-gateway-address-1', + // This IP address comes from IP Pool 1; a proper IP address will come from one of the IP pools + address: '123.4.56.3', + description: 'the IP address for an internet gateway', + internet_gateway_id: internetGateway1.id, + time_created, + time_modified, +} + +export const internetGatewayIpAddresses: Json[] = [ + internetGatewayIpAddress1, +] + +const internetGatewayIpPool1: Json = { + id: '1d5e5a1f-0b2b-4d5b-8b9d-2d4b3e0c6gb9', + name: 'internet-gateway-pool-1', + description: 'An IP pool for an internet gateway', + internet_gateway_id: internetGateway1.id, + ip_pool_id: ipPool1.id, + time_created, + time_modified, +} + +const internetGatewayIpPool2: Json = { + id: 'd5e5a1f1-0b2b-4d5b-8b9d-2d4b3e0c6b9c', + name: 'interent-gateway-pool-2', + description: 'another IP pool for an internet gateway', + internet_gateway_id: internetGateway2.id, + ip_pool_id: ipPool2.id, + time_created, + time_modified, +} + +export const internetGatewayIpPools: Json[] = [ + internetGatewayIpPool1, + internetGatewayIpPool2, +] diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 534125ff80..550699be68 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -19,7 +19,7 @@ export const ipPool1: Json = { time_modified: new Date().toISOString(), } -const ipPool2: Json = { +export const ipPool2: Json = { id: 'af2fbe06-b21d-4364-96b7-a58220bc3242', name: 'ip-pool-2', description: 'VPN IPs', @@ -27,7 +27,7 @@ const ipPool2: Json = { time_modified: new Date().toISOString(), } -const ipPool3: Json = { +export const ipPool3: Json = { id: '8929a9ec-03d7-4027-8bf3-dda76627de07', name: 'ip-pool-3', description: '', @@ -35,7 +35,7 @@ const ipPool3: Json = { time_modified: new Date().toISOString(), } -const ipPool4: Json = { +export const ipPool4: Json = { id: 'a5f395a8-650e-44c9-9af8-ec21d890f61c', name: 'ip-pool-4', description: '', diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index 13efe3d9b5..ecbe6b4177 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -31,6 +31,8 @@ export const lookupById = (table: T[], id: string) => return item } +const paginationParams = ['limit', 'page_token', 'sort_by'] + /** * Given an object representing (potentially) parent selectors for a resource, * throw an error if any of the keys in that object have truthy values. For @@ -44,7 +46,7 @@ function ensureNoParentSelectors( parentSelector: Record ) { const keysWithValues = Object.entries(parentSelector) - .filter(([_, v]) => v) + .filter(([k, v]) => v && !paginationParams.includes(k)) .map(([k]) => k) if (keysWithValues.length > 0) { const message = `when ${resourceLabel} is specified by ID, ${commaSeries(keysWithValues, 'and')} should not be specified` @@ -215,6 +217,44 @@ export const lookup = { return subnet }, + internetGateway({ + gateway: id, + ...vpcSelector + }: Sel.InternetGateway): Json { + if (!id) throw notFoundErr('no internet gateway specified') + + if (isUuid(id)) { + ensureNoParentSelectors('internet gateway', vpcSelector) + return lookupById(db.internetGateways, id) + } + + const vpc = lookup.vpc(vpcSelector) + const internetGateway = db.internetGateways.find( + (ig) => ig.vpc_id === vpc.id && ig.name === id + ) + if (!internetGateway) throw notFoundErr(`internet gateway '${id}'`) + + return internetGateway + }, + internetGatewayIpAddress({ + address: id, + ...gatewaySelector + }: Sel.InternetGatewayIpAddress): Json { + if (!id) throw notFoundErr('no IP address specified') + + if (isUuid(id)) { + ensureNoParentSelectors('IP address', gatewaySelector) + return lookupById(db.internetGatewayIpAddresses, id) + } + + const gateway = lookup.internetGateway(gatewaySelector) + const ip = db.internetGatewayIpAddresses.find( + (i) => i.internet_gateway_id === gateway.id && i.name === id + ) + if (!ip) throw notFoundErr(`IP address '${id}'`) + + return ip + }, image({ image: id, project: projectId }: Sel.Image): Json { if (!id) throw notFoundErr('no image specified') @@ -405,6 +445,9 @@ const initDb = { images: [...mock.images], ephemeralIps: [...mock.ephemeralIps], instances: [...mock.instances], + internetGatewayIpAddresses: [...mock.internetGatewayIpAddresses], + internetGatewayIpPools: [...mock.internetGatewayIpPools], + internetGateways: [...mock.internetGateways], ipPools: [...mock.ipPools], ipPoolSilos: [...mock.ipPoolSilos], ipPoolRanges: [...mock.ipPoolRanges], diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 4386ebca17..9ea126fd98 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1159,6 +1159,26 @@ export const handlers = makeHandlers({ return { rules: R.sortBy(rules, (r) => r.name) } }, + internetGatewayList({ query }) { + const vpc = lookup.vpc(query) + const gateways = db.internetGateways.filter((g) => g.vpc_id === vpc.id) + return paginated(query, gateways) + }, + internetGatewayView: ({ path, query }) => lookup.internetGateway({ ...path, ...query }), + internetGatewayIpPoolList({ query }) { + const gateway = lookup.internetGateway(query) + const pools = db.internetGatewayIpPools.filter( + (p) => p.internet_gateway_id === gateway.id + ) + return paginated(query, pools) + }, + internetGatewayIpAddressList({ query }) { + const gateway = lookup.internetGateway(query) + const addresses = db.internetGatewayIpAddresses.filter( + (a) => a.internet_gateway_id === gateway.id + ) + return paginated(query, addresses) + }, vpcRouterList({ query }) { const vpc = lookup.vpc(query) const routers = db.vpcRouters.filter((r) => r.vpc_id === vpc.id) @@ -1527,12 +1547,8 @@ export const handlers = makeHandlers({ internetGatewayDelete: NotImplemented, internetGatewayIpAddressCreate: NotImplemented, internetGatewayIpAddressDelete: NotImplemented, - internetGatewayIpAddressList: NotImplemented, internetGatewayIpPoolCreate: NotImplemented, internetGatewayIpPoolDelete: NotImplemented, - internetGatewayIpPoolList: NotImplemented, - internetGatewayList: NotImplemented, - internetGatewayView: NotImplemented, ipPoolServiceRangeAdd: NotImplemented, ipPoolServiceRangeList: NotImplemented, ipPoolServiceRangeRemove: NotImplemented, diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index caab75d1a6..e3614f9acb 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -134,6 +134,22 @@ export const routerRoutes: Json> = [ type: 'drop', }, }, + { + ...routeBase, + vpc_router_id: customRouter.id, + id: '550e8400-e29b-41d4-a716-446655440000', + name: 'dc2', + description: 'route to datacenter 2', + kind: 'custom', + target: { + type: 'internet_gateway', + value: 'internet-gateway-1', + }, + destination: { + type: 'ip_net', + value: '45.154.216.0/24', + }, + }, ] export const vpcSubnet: Json = { diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index dadcc16be5..f2e9ef10ef 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -279,6 +279,9 @@ test('create router route', async ({ page }) => { test('edit and delete router route', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') + const table = page.getByRole('table') + await expect(table.locator('tbody >> tr')).toHaveCount(2) + const form = page.getByRole('dialog', { name: 'Edit route' }) await expect(form).toBeHidden() @@ -306,7 +309,6 @@ test('edit and delete router route', async ({ page }) => { await submitButton.click() await expect(form).toBeHidden() - const table = page.getByRole('table') await expectRowVisible(table, { Name: 'new-name', Destination: 'VPC subnetmock-subnet', @@ -316,6 +318,99 @@ test('edit and delete router route', async ({ page }) => { // delete the route await clickRowAction(page, 'new-name', 'Delete') await page.getByRole('button', { name: 'Confirm' }).click() - await expect(table).toBeHidden() - await expect(page.getByText('No routes')).toBeVisible() + // expect 1 row in table + await expect(table.locator('tbody >> tr')).toHaveCount(1) +}) + +test('can view internet gateways', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs/mock-vpc') + await page.getByRole('tab', { name: 'Internet Gateways' }).click() + + const table = page.getByRole('table') + const rows = table.locator('tbody >> tr') + await expect(rows).toHaveCount(2) + + await expectRowVisible(table, { + name: 'internet-gateway-1', + description: 'internet gateway 1', + 'Attached IP Address': '123.4.56.3', + 'Attached IP Pool': 'ip-pool-1', + Routes: '1', + }) + await expectRowVisible(table, { + name: 'internet-gateway-2', + description: 'internet gateway 2', + 'Attached IP Address': '—', + 'Attached IP Pool': 'ip-pool-2', + Routes: '—', + }) + + await page.getByRole('link', { name: 'internet-gateway-1' }).click() + await expect(page).toHaveURL( + '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1' + ) + const sidemodal = page.getByLabel('Internet Gateway') + + await expect(sidemodal.getByText('123.4.56.3')).toBeVisible() + + // close the sidemodal + await sidemodal.getByRole('button', { name: 'Close' }).click() + await expect(sidemodal).toBeHidden() + + await page.getByRole('link', { name: 'internet-gateway-2' }).click() + await expect(sidemodal.getByText('This internet gateway does not have any')).toBeVisible() +}) + +test('internet gateway shows proper list of routes targeting it', async ({ page }) => { + // open up the internet gateway detail page for internet-gateway-1 + 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 sidemodal.getByRole('button', { name: 'Close' }).click() + await expect(sidemodal).toBeHidden() + // check for the route count; which should be 1 + await expect(page.getByRole('link', { name: '1', exact: true })).toBeVisible() + // go to the Routers tab + await page.getByRole('tab', { name: 'Routers' }).click() + // click on the mock-custom-router to go to the router detail page + await page.getByRole('link', { name: 'mock-custom-router' }).click() + // expect to be on the view page + await expect(page).toHaveURL( + '/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') + await page.getByRole('textbox', { name: 'Destination value' }).fill('1.2.3.4') + await selectOption(page, 'Target type', 'Internet gateway') + await selectOption(page, 'Target value', 'internet-gateway-1') + await page.getByRole('button', { name: 'Create route' }).click() + + // go back to the mock-vpc page by clicking on the link in the header + 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 + 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) + + // click on the new-route link to go to the detail page + await sidemodal.getByRole('link', { name: 'new-route' }).click() + // expect to be on the view page + await expect(page).toHaveURL( + '/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router/routes/new-route/edit' + ) })