diff --git a/app/api/hooks.ts b/app/api/hooks.ts index a6c464f403..70c70a7778 100644 --- a/app/api/hooks.ts +++ b/app/api/hooks.ts @@ -174,7 +174,14 @@ export const getUsePrefetchedApiQuery = }) invariant( result.data, - `Expected query to be prefetched. Key: ${JSON.stringify(queryKey)}` + `Expected query to be prefetched. +Key: ${JSON.stringify(queryKey)} +Ensure the following: +• loader is running +• query matches in both the loader and the component +• request isn't erroring-out server-side (check the Networking tab) +• mock API endpoint is implemented in handlers.ts +` ) // TS infers non-nullable on a freestanding variable, but doesn't like to do // it on a property. So we give it a hint diff --git a/app/api/path-params.ts b/app/api/path-params.ts index a14c1c38c2..eaeab465aa 100644 --- a/app/api/path-params.ts +++ b/app/api/path-params.ts @@ -15,6 +15,8 @@ export type SiloImage = { image?: string } export type NetworkInterface = Merge export type Snapshot = Merge export type Vpc = Merge +export type VpcRouter = Merge +export type VpcRouterRoute = Merge export type VpcSubnet = Merge export type FirewallRule = Merge export type Silo = { silo?: string } diff --git a/app/components/TopBarPicker.tsx b/app/components/TopBarPicker.tsx index 48ecfa4073..f6d1ffd6b9 100644 --- a/app/components/TopBarPicker.tsx +++ b/app/components/TopBarPicker.tsx @@ -15,7 +15,13 @@ import { Success12Icon, } from '@oxide/design-system/icons/react' -import { useInstanceSelector, useIpPoolSelector, useSiloSelector } from '~/hooks' +import { + useInstanceSelector, + useIpPoolSelector, + useSiloSelector, + useVpcRouterSelector, + useVpcSelector, +} from '~/hooks' import { useCurrentUser } from '~/layouts/AuthenticatedLayout' import { PAGE_SIZE } from '~/table/QueryTable' import { Button } from '~/ui/lib/Button' @@ -229,7 +235,7 @@ export function SiloPicker() { export function IpPoolPicker() { // picker only shows up when a pool is in scope const { pool: poolName } = useIpPoolSelector() - const { data } = useApiQuery('ipPoolList', { query: { limit: 10 } }) + const { data } = useApiQuery('ipPoolList', { query: { limit: PAGE_SIZE } }) const items = (data?.items || []).map((pool) => ({ label: pool.name, to: pb.ipPool({ pool: pool.name }), @@ -246,6 +252,51 @@ export function IpPoolPicker() { ) } +/** Used when drilling down into a VPC from the Silo view. */ +export function VpcPicker() { + // picker only shows up when a VPC is in scope + const { project, vpc } = useVpcSelector() + const { data } = useApiQuery('vpcList', { query: { project, limit: PAGE_SIZE } }) + const items = (data?.items || []).map((v) => ({ + label: v.name, + to: pb.vpc({ project, vpc: v.name }), + })) + + return ( + + ) +} + +/** Used when drilling down into a VPC Router from the Silo view. */ +export function VpcRouterPicker() { + // picker only shows up when a router is in scope + const { project, vpc, router } = useVpcRouterSelector() + const { data } = useApiQuery('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + const items = (data?.items || []).map((r) => ({ + label: r.name, + to: pb.vpcRouter({ vpc, project, router: r.name }), + })) + + return ( + + ) +} + const NoProjectLogo = () => (
diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 734f0197b4..5ae5fbd5bd 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -50,7 +50,7 @@ export function EditSubnetForm() { }, }) - const defaultValues = R.pick(subnet, ['name', 'description']) satisfies VpcSubnetUpdate + const defaultValues: VpcSubnetUpdate = R.pick(subnet, ['name', 'description']) const form = useForm({ defaultValues }) diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx new file mode 100644 index 0000000000..089d47aa6f --- /dev/null +++ b/app/forms/vpc-router-create.tsx @@ -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 { useNavigate } from 'react-router-dom' + +import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { useForm, useVpcSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +const defaultValues: VpcRouterCreate = { + name: '', + description: '', +} + +export function CreateRouterSideModalForm() { + const queryClient = useApiQueryClient() + const vpcSelector = useVpcSelector() + const navigate = useNavigate() + + const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) + + const createRouter = useApiMutation('vpcRouterCreate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterList') + addToast({ content: 'Your router has been created' }) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + + return ( + createRouter.mutate({ query: vpcSelector, body })} + loading={createRouter.isPending} + submitError={createRouter.error} + > + + + + ) +} diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx new file mode 100644 index 0000000000..eff66fd6b5 --- /dev/null +++ b/app/forms/vpc-router-edit.tsx @@ -0,0 +1,87 @@ +/* + * 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 { + useNavigate, + type LoaderFunctionArgs, + type NavigateFunction, +} from 'react-router-dom' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, + type VpcRouterUpdate, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { getVpcRouterSelector, useForm, useVpcRouterSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +EditRouterSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { router, project, vpc } = getVpcRouterSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + return null +} + +export function EditRouterSideModalForm() { + const queryClient = useApiQueryClient() + const routerSelector = useVpcRouterSelector() + const { project, vpc, router } = routerSelector + const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + const navigate = useNavigate() + + const onDismiss = (navigate: NavigateFunction) => { + navigate(pb.vpcRouters({ project, vpc })) + } + + const editRouter = useApiMutation('vpcRouterUpdate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterList') + addToast({ content: 'Your router has been updated' }) + navigate(pb.vpcRouters({ project, vpc })) + }, + }) + + const defaultValues: VpcRouterUpdate = { + name: router, + description: routerData.description, + } + + const form = useForm({ defaultValues }) + + return ( + onDismiss(navigate)} + onSubmit={(body) => + editRouter.mutate({ + path: { router }, + query: { project, vpc }, + body, + }) + } + loading={editRouter.isPending} + submitError={editRouter.error} + > + + + + ) +} diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx new file mode 100644 index 0000000000..0ca0241d1e --- /dev/null +++ b/app/forms/vpc-router-route-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 { useNavigate } from 'react-router-dom' + +import { useApiMutation, useApiQueryClient, type RouterRouteCreate } 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 { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { fields, targetValueDescription } from '~/forms/vpc-router-route/shared' +import { useForm, useVpcRouterSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { pb } from '~/util/path-builder' + +const defaultValues: RouterRouteCreate = { + name: '', + description: '', + destination: { type: 'ip', value: '' }, + target: { type: 'ip', value: '' }, +} + +export function CreateRouterRouteSideModalForm() { + const queryClient = useApiQueryClient() + const routerSelector = useVpcRouterSelector() + const navigate = useNavigate() + + const onDismiss = () => { + navigate(pb.vpcRouter(routerSelector)) + } + + const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been created' }) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + const targetType = form.watch('target.type') + + return ( + + createRouterRoute.mutate({ + query: routerSelector, + body: { + name, + description, + destination, + // drop has no value + target: target.type === 'drop' ? { type: target.type } : target, + }, + }) + } + loading={createRouterRoute.isPending} + submitError={createRouterRoute.error} + > + + + + + { + // 'outbound' is only valid option when targetType is 'internet_gateway' + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + if (value === 'drop') { + form.setValue('target.value', '') + } + }} + /> + {targetType !== 'drop' && ( + + )} + + ) +} diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx new file mode 100644 index 0000000000..7554fe9cce --- /dev/null +++ b/app/forms/vpc-router-route-edit.tsx @@ -0,0 +1,136 @@ +/* + * 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 { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' +import * as R from 'remeda' + +import { + apiQueryClient, + useApiMutation, + useApiQueryClient, + usePrefetchedApiQuery, + type RouterRouteUpdate, +} 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 { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { + fields, + routeFormMessage, + targetValueDescription, +} from '~/forms/vpc-router-route/shared' +import { getVpcRouterRouteSelector, useForm, useVpcRouterRouteSelector } from '~/hooks' +import { addToast } from '~/stores/toast' +import { Message } from '~/ui/lib/Message' +import { pb } from '~/util/path-builder' + +EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, vpc, router, route } = getVpcRouterRouteSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterRouteView', { + path: { route }, + query: { project, vpc, router }, + }) + return null +} + +export function EditRouterRouteSideModalForm() { + const queryClient = useApiQueryClient() + const routeSelector = useVpcRouterRouteSelector() + const { project, vpc, router: routerName, route: routeName } = routeSelector + const navigate = useNavigate() + const { data: route } = usePrefetchedApiQuery('vpcRouterRouteView', { + path: { route: routeName }, + query: { project, vpc, router: routerName }, + }) + + const defaultValues: RouterRouteUpdate = R.pick(route, [ + 'name', + 'description', + 'target', + 'destination', + ]) + + const onDismiss = () => { + navigate(pb.vpcRouter({ project, vpc, router: routerName })) + } + + const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { + onSuccess() { + queryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been updated' }) + onDismiss() + }, + }) + + const form = useForm({ defaultValues }) + const targetType = form.watch('target.type') + + let isDisabled = false + let disabledReason = '' + + // Can simplify this if there aren't other disabling reasons + if (route?.kind === 'vpc_subnet') { + isDisabled = true + disabledReason = routeFormMessage.vpcSubnetNotModifiable + } + + return ( + + updateRouterRoute.mutate({ + query: { project, vpc, router: routerName }, + path: { route: routeName }, + body: { + name, + description, + destination, + // drop has no value + target: target.type === 'drop' ? { type: target.type } : target, + }, + }) + } + loading={updateRouterRoute.isPending} + submitError={updateRouterRoute.error} + > + {isDisabled && } + + + + + { + // 'outbound' is only valid option when targetType is 'internet_gateway' + if (value === 'internet_gateway') { + form.setValue('target.value', 'outbound') + } + if (value === 'drop') { + form.setValue('target.value', '') + } + }} + /> + {targetType !== 'drop' && ( + + )} + + ) +} diff --git a/app/forms/vpc-router-route/shared.tsx b/app/forms/vpc-router-route/shared.tsx new file mode 100644 index 0000000000..0063aa9544 --- /dev/null +++ b/app/forms/vpc-router-route/shared.tsx @@ -0,0 +1,74 @@ +/* + * 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 { RouteDestination, RouteTarget } from '~/api' + +// VPCs can not be specified as a destination in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L363 +const destTypes: Record, string> = { + ip: 'IP', + ip_net: 'IP network', + subnet: 'Subnet', +} + +// Subnets and VPCs cannot be used as a target in custom routers +// https://github.com/oxidecomputer/omicron/blob/4f27433d1bca57eb02073a4ea1cd14557f70b8c7/nexus/src/app/vpc_router.rs#L362-L368 +const targetTypes: Record, string> = { + ip: 'IP', + instance: 'Instance', + internet_gateway: 'Internet gateway', + drop: 'Drop', +} + +const toItems = (mapping: Record) => + Object.entries(mapping).map(([value, label]) => ({ value, label })) + +export const fields = { + destType: { + name: 'destination.type' as const, + items: toItems(destTypes), + label: 'Destination type', + placeholder: 'Select a destination type', + required: true, + }, + destValue: { + name: 'destination.value' as const, + label: 'Destination value', + placeholder: 'Enter a destination value', + required: true, + }, + targetType: { + name: 'target.type' as const, + items: toItems(targetTypes), + label: 'Target type', + placeholder: 'Select a target type', + required: true, + }, + targetValue: { + name: 'target.value' as const, + label: 'Target value', + placeholder: 'Enter a target value', + required: true, + }, +} + +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 + noDeletingRoutesOnSystemRouter: 'System routes can not be deleted', +} + +export const targetValueDescription = (targetType: RouteTarget['type']) => + targetType === 'internet_gateway' + ? routeFormMessage.internetGatewayTargetValue + : undefined diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 35932cce8d..348e48d3b1 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -37,6 +37,8 @@ export const getFloatingIpSelector = requireParams('project', 'floatingIp') export const getInstanceSelector = requireParams('project', 'instance') export const getVpcSelector = requireParams('project', 'vpc') 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 getSiloSelector = requireParams('silo') export const getSiloImageSelector = requireParams('image') @@ -79,6 +81,8 @@ export const useProjectSnapshotSelector = () => useSelectedParams(getProjectSnapshotSelector) export const useInstanceSelector = () => useSelectedParams(getInstanceSelector) export const useVpcSelector = () => useSelectedParams(getVpcSelector) +export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector) +export const useVpcRouterRouteSelector = () => useSelectedParams(getVpcRouterRouteSelector) export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector) export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector) export const useSiloSelector = () => useSelectedParams(getSiloSelector) diff --git a/app/layouts/ProjectLayout.tsx b/app/layouts/ProjectLayout.tsx index db4c02b88a..ebb53d0079 100644 --- a/app/layouts/ProjectLayout.tsx +++ b/app/layouts/ProjectLayout.tsx @@ -26,7 +26,13 @@ import { } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' -import { InstancePicker, ProjectPicker, SiloSystemPicker } from '~/components/TopBarPicker' +import { + InstancePicker, + ProjectPicker, + SiloSystemPicker, + VpcPicker, + VpcRouterPicker, +} from '~/components/TopBarPicker' import { getProjectSelector, useProjectSelector, useQuickActions } from '~/hooks' import { Divider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -54,7 +60,7 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { const projectSelector = useProjectSelector() const { data: project } = usePrefetchedApiQuery('projectView', { path: projectSelector }) - const { instance } = useParams() + const { instance, router, vpc } = useParams() const { pathname } = useLocation() useQuickActions( useMemo( @@ -85,6 +91,8 @@ export function ProjectLayout({ overrideContentPane }: ProjectLayoutProps) { {instance && } + {vpc && } + {router && } diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index d009cd8f59..abd7c542c4 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -30,10 +30,11 @@ import { getInstanceSelector, useInstanceSelector, useProjectSelector } from '~/ import { confirmAction } from '~/stores/confirm-action' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { LinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' -import { Columns, DescriptionCell } from '~/table/columns/common' +import { Columns } from '~/table/columns/common' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' import { CopyableIp } from '~/ui/lib/CopyableIp' diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx new file mode 100644 index 0000000000..640ce58ed2 --- /dev/null +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -0,0 +1,234 @@ +/* + * 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, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' + +import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' + +import { + apiQueryClient, + useApiMutation, + usePrefetchedApiQuery, + type RouteDestination, + type RouterRoute, + type RouteTarget, +} from '~/api' +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { routeFormMessage } from '~/forms/vpc-router-route/shared' +import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { DescriptionCell } from '~/table/cells/DescriptionCell' +import { TypeValueCell } from '~/table/cells/TypeValueCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { Badge } from '~/ui/lib/Badge' +import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableControls, TableTitle } from '~/ui/lib/Table' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +RouterPage.loader = async function ({ params }: LoaderFunctionArgs) { + const { project, vpc, router } = getVpcRouterSelector(params) + await Promise.all([ + apiQueryClient.prefetchQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }), + apiQueryClient.prefetchQuery('vpcRouterRouteList', { + query: { project, router, vpc, limit: PAGE_SIZE }, + }), + ]) + return null +} + +const routeTypes = { + drop: 'Drop', + ip: 'IP', + ip_net: 'IP network', + instance: 'Instance', + internet_gateway: 'Gateway', + subnet: 'VPC subnet', + vpc: 'VPC', +} + +// All will have a type and a value except `Drop`, which only has a type +const RouterRouteTypeValueBadge = ({ + type, + value, +}: { + type: (RouteDestination | RouteTarget)['type'] + value?: string +}) => { + return value ? ( + + ) : ( + {routeTypes[type]} + ) +} + +export function RouterPage() { + const { project, vpc, router } = useVpcRouterSelector() + const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', { + path: { router }, + query: { project, vpc }, + }) + + const deleteRouterRoute = useApiMutation('vpcRouterRouteDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('vpcRouterRouteList') + addToast({ content: 'Your route has been deleted' }) + }, + }) + + const actions = useMemo( + () => [ + { + label: 'Copy ID', + onActivate() { + window.navigator.clipboard.writeText(routerData.id || '') + }, + }, + ], + [routerData] + ) + const { Table } = useQueryTable('vpcRouterRouteList', { query: { project, router, vpc } }) + + const emptyState = ( + } + title="No routes" + body="Add a route to see it here" + buttonText="Add route" + buttonTo={pb.vpcRouterRoutesNew({ project, vpc, router })} + /> + ) + const navigate = useNavigate() + + const routerRoutesColHelper = createColumnHelper() + + const routerRoutesStaticCols = [ + routerRoutesColHelper.accessor('name', { header: 'Name' }), + routerRoutesColHelper.accessor('kind', { + header: 'Kind', + cell: (info) => {info.getValue().replace('_', ' ')}, + }), + routerRoutesColHelper.accessor('destination', { + header: 'Destination', + cell: (info) => , + }), + routerRoutesColHelper.accessor('target', { + header: 'Target', + cell: (info) => , + }), + ] + + const makeRangeActions = useCallback( + (routerRoute: RouterRoute): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + apiQueryClient.setQueryData( + 'vpcRouterRouteView', + { path: { route: routerRoute.name }, query: { project, vpc, router } }, + routerRoute + ) + navigate(pb.vpcRouterRouteEdit({ project, vpc, router, route: routerRoute.name })) + }, + disabled: + routerRoute.kind === 'vpc_subnet' && routeFormMessage.vpcSubnetNotModifiable, + }, + { + label: 'Delete', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + deleteRouterRoute.mutateAsync({ path: { route: routerRoute.id } }), + errorTitle: 'Could not remove route', + modalTitle: 'Confirm remove route', + modalContent: ( +

+ Are you sure you want to delete route {routerRoute.name}? +

+ ), + actionType: 'danger', + }), + disabled: + routerData.kind === 'system' && routeFormMessage.noDeletingRoutesOnSystemRouter, + }, + ], + [navigate, project, vpc, router, deleteRouterRoute, routerData] + ) + const columns = useColsWithActions(routerRoutesStaticCols, makeRangeActions) + // user-provided routes cannot be added to a system router + // https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L205 + const canCreateNewRoute = routerData.kind === 'custom' + + return ( + <> + + }>{router} +
+ } + summary="Routers summary copy TK" + links={[docLinks.routers]} + /> + +
+
+ + + + + + + {routerData.kind} + + + + + + + + + + + + + Routes + {canCreateNewRoute ? ( + + New route + + ) : ( + + New route + + )} + + + + + ) +} diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 514401bc25..b1bf4073e5 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -12,7 +12,7 @@ import { Networking24Icon } from '@oxide/design-system/icons/react' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks' -import { EmptyCell } from '~/table/cells/EmptyCell' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { DateTime } from '~/ui/lib/DateTime' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -42,7 +42,7 @@ export function VpcPage() { - {vpc.description || } + {vpc.dnsName} @@ -59,6 +59,7 @@ export function VpcPage() { Firewall Rules Subnets + Routers ) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx new file mode 100644 index 0000000000..bc8d98c667 --- /dev/null +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -0,0 +1,111 @@ +/* + * 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, useMemo } from 'react' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' + +import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' + +import { getVpcSelector, useVpcSelector } from '~/hooks' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { PAGE_SIZE, useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { pb } from '~/util/path-builder' + +const colHelper = createColumnHelper() + +VpcRoutersTab.loader = async ({ params }: LoaderFunctionArgs) => { + const { project, vpc } = getVpcSelector(params) + await apiQueryClient.prefetchQuery('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + return null +} + +export function VpcRoutersTab() { + const vpcSelector = useVpcSelector() + const navigate = useNavigate() + const { project, vpc } = vpcSelector + const { Table } = useQueryTable('vpcRouterList', { + query: { project, vpc, limit: PAGE_SIZE }, + }) + + const emptyState = ( + + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('name', { + cell: makeLinkCell((router) => pb.vpcRouter({ ...vpcSelector, router })), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('timeCreated', Columns.timeCreated), + ], + [vpcSelector] + ) + + const deleteRouter = useApiMutation('vpcRouterDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('vpcRouterList') + addToast({ content: 'Your router has been deleted' }) + }, + }) + + const makeActions = useCallback( + (router: VpcRouter): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + // the edit view has its own loader, but we can make the modal open + // instantaneously by preloading the fetch result + apiQueryClient.setQueryData( + 'vpcRouterView', + { path: { router: router.name } }, + router + ) + navigate(pb.vpcRouterEdit({ project, vpc, router: router.name })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + deleteRouter.mutateAsync({ + path: { router: router.name }, + query: { project, vpc }, + }), + label: router.name, + }), + }, + ], + [deleteRouter, project, vpc, navigate] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + + return ( + <> +
+ New router +
+
+ + + ) +} diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 315f02ab9b..e564356a8c 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -13,7 +13,7 @@ import { Cloud16Icon, Cloud24Icon, NextArrow12Icon } from '@oxide/design-system/ import { DocsPopover } from '~/components/DocsPopover' import { QueryParamTabs } from '~/components/QueryParamTabs' import { getSiloSelector, useSiloSelector } from '~/hooks' -import { EmptyCell } from '~/table/cells/EmptyCell' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { PAGE_SIZE } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' import { DateTime } from '~/ui/lib/DateTime' @@ -68,7 +68,7 @@ export function SiloPage() { {silo.id} - {silo.description || } + diff --git a/app/routes.tsx b/app/routes.tsx index 22e27ccb36..27456e57b0 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -35,6 +35,10 @@ import { CreateSubnetForm } from './forms/subnet-create' import { EditSubnetForm } from './forms/subnet-edit' import { CreateVpcSideModalForm } from './forms/vpc-create' import { EditVpcSideModalForm } from './forms/vpc-edit' +import { CreateRouterSideModalForm } from './forms/vpc-router-create' +import { EditRouterSideModalForm } from './forms/vpc-router-edit' +import { CreateRouterRouteSideModalForm } from './forms/vpc-router-route-create' +import { EditRouterRouteSideModalForm } from './forms/vpc-router-route-edit' import type { CrumbFunc } from './hooks/use-title' import { AuthenticatedLayout } from './layouts/AuthenticatedLayout' import { AuthLayout } from './layouts/AuthLayout' @@ -62,7 +66,9 @@ import { NetworkingTab } from './pages/project/instances/instance/tabs/Networkin import { StorageTab } from './pages/project/instances/instance/tabs/StorageTab' import { InstancesPage } from './pages/project/instances/InstancesPage' import { SnapshotsPage } from './pages/project/snapshots/SnapshotsPage' +import { RouterPage } from './pages/project/vpcs/RouterPage' import { VpcFirewallRulesTab } from './pages/project/vpcs/VpcPage/tabs/VpcFirewallRulesTab' +import { VpcRoutersTab } from './pages/project/vpcs/VpcPage/tabs/VpcRoutersTab' import { VpcSubnetsTab } from './pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab' import { VpcPage } from './pages/project/vpcs/VpcPage/VpcPage' import { VpcsPage } from './pages/project/vpcs/VpcsPage' @@ -390,10 +396,42 @@ export const routes = createRoutesFromElements( handle={{ crumb: 'Edit Subnet' }} /> + } loader={VpcRoutersTab.loader}> + + } + loader={EditRouterSideModalForm.loader} + handle={{ crumb: 'Edit Router' }} + /> + + } + handle={{ crumb: 'New Router' }} + /> + - + } + loader={RouterPage.loader} + handle={{ crumb: 'Routes' }} + path="vpcs/:vpc/routers/:router" + > + } + handle={{ crumb: 'New Route' }} + /> + } + loader={EditRouterRouteSideModalForm.loader} + handle={{ crumb: 'Edit Route' }} + /> + } loader={FloatingIpsPage.loader}> + text ? : diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index c8de914199..6bac4b2426 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -8,10 +8,8 @@ import { filesize } from 'filesize' +import { DescriptionCell } from '~/table/cells/DescriptionCell' import { DateTime } from '~/ui/lib/DateTime' -import { Truncate } from '~/ui/lib/Truncate' - -import { EmptyCell } from '../cells/EmptyCell' // the full type of the info arg is CellContext from RT, but in these // cells we only care about the return value of getValue @@ -30,9 +28,6 @@ function sizeCell(info: Info) { ) } -export const DescriptionCell = ({ text }: { text?: string }) => - text ? : - /** Columns used in a bunch of tables */ export const Columns = { /** Truncates text if too long, full text in tooltip */ diff --git a/app/util/links.ts b/app/util/links.ts index b4d6f73335..c4be10e4b2 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -23,6 +23,8 @@ export const links = { 'https://docs.oxide.computer/guides/key-entities-and-concepts#_projects', projectsDocs: 'https://docs.oxide.computer/guides/onboarding-projects', quickStart: 'https://docs.oxide.computer/guides/quickstart', + routersDocs: + 'https://docs.oxide.computer/guides/configuring-guest-networking#_custom_routers', sledDocs: 'https://docs.oxide.computer/guides/architecture/service-processors#_server_sled', snapshotsDocs: @@ -78,6 +80,10 @@ export const docLinks = { href: links.quickStart, linkText: 'Quick Start', }, + routers: { + href: links.routersDocs, + linkText: 'Custom Routers', + }, sleds: { href: links.sledDocs, linkText: 'Server Sleds', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index 68b0197014..76e39846ef 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -24,6 +24,8 @@ const params = { pool: 'pl', rule: 'fr', subnet: 'su', + router: 'r', + route: 'rr', } test('path builder', () => { @@ -92,6 +94,12 @@ 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", + "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", + "vpcRouterRoutesNew": "/projects/p/vpcs/v/routers/r/routes-new", + "vpcRouters": "/projects/p/vpcs/v/routers", + "vpcRoutersNew": "/projects/p/vpcs/v/routers-new", "vpcSubnets": "/projects/p/vpcs/v/subnets", "vpcSubnetsEdit": "/projects/p/vpcs/v/subnets/su/edit", "vpcSubnetsNew": "/projects/p/vpcs/v/subnets-new", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index ce59f1d358..1709b19c59 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -22,6 +22,8 @@ type SiloImage = Required type IpPool = Required type FloatingIp = Required type FirewallRule = Required +type VpcRouter = Required +type VpcRouterRoute = Required type VpcSubnet = Required // these are used as the basis for many routes but are not themselves routes we @@ -83,6 +85,14 @@ export const pb = { `${pb.vpcFirewallRulesNew(params)}/${params.rule}`, vpcFirewallRuleEdit: (params: FirewallRule) => `${pb.vpcFirewallRules(params)}/${params.rule}/edit`, + vpcRouters: (params: Vpc) => `${vpcBase(params)}/routers`, + vpcRoutersNew: (params: Vpc) => `${vpcBase(params)}/routers-new`, + vpcRouter: (params: VpcRouter) => `${pb.vpcRouters(params)}/${params.router}`, + vpcRouterEdit: (params: VpcRouter) => `${pb.vpcRouter(params)}/edit`, + vpcRouterRouteEdit: (params: VpcRouterRoute) => + `${pb.vpcRouter(params)}/routes/${params.route}/edit`, + vpcRouterRoutesNew: (params: VpcRouter) => `${pb.vpcRouter(params)}/routes-new`, + vpcSubnets: (params: Vpc) => `${vpcBase(params)}/subnets`, vpcSubnetsNew: (params: Vpc) => `${vpcBase(params)}/subnets-new`, vpcSubnetsEdit: (params: VpcSubnet) => `${pb.vpcSubnets(params)}/${params.subnet}/edit`, diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index fa8d5b6c42..c271f0d197 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -138,6 +138,33 @@ export const lookup = { return vpc }, + vpcRouter({ router: id, ...vpcSelector }: PP.VpcRouter): Json { + if (!id) throw notFoundErr('no router specified') + + if (isUuid(id)) return lookupById(db.vpcRouters, id) + + const vpc = lookup.vpc(vpcSelector) + const router = db.vpcRouters.find((r) => r.vpc_id === vpc.id && r.name === id) + if (!router) throw notFoundErr(`router '${id}'`) + + return router + }, + vpcRouterRoute({ + route: id, + ...routerSelector + }: PP.VpcRouterRoute): Json { + if (!id) throw notFoundErr('no route specified') + + if (isUuid(id)) return lookupById(db.vpcRouterRoutes, id) + + const router = lookup.vpcRouter(routerSelector) + const route = db.vpcRouterRoutes.find( + (r) => r.vpc_router_id === router.id && r.name === id + ) + if (!route) throw notFoundErr(`route '${id}'`) + + return route + }, vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') @@ -326,6 +353,8 @@ const initDb = { sshKeys: [...mock.sshKeys], users: [...mock.users], vpcFirewallRules: [...mock.firewallRules], + vpcRouters: [...mock.vpcRouters], + vpcRouterRoutes: [...mock.routerRoutes], vpcs: [...mock.vpcs], vpcSubnets: [mock.vpcSubnet], } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index f7c0f40325..c84bbdc03e 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1053,6 +1053,92 @@ export const handlers = makeHandlers({ return { rules: R.sortBy(rules, (r) => r.name) } }, + vpcRouterList({ query }) { + const vpc = lookup.vpc(query) + const routers = db.vpcRouters.filter((r) => r.vpc_id === vpc.id) + return paginated(query, routers) + }, + vpcRouterCreate({ body, query }) { + const vpc = lookup.vpc(query) + errIfExists(db.vpcRouters, { vpc_id: vpc.id, name: body.name }) + + const newRouter: Json = { + id: uuid(), + vpc_id: vpc.id, + kind: 'custom', + ...body, + ...getTimestamps(), + } + db.vpcRouters.push(newRouter) + return json(newRouter, { status: 201 }) + }, + vpcRouterView: ({ path, query }) => lookup.vpcRouter({ ...path, ...query }), + vpcRouterUpdate({ body, path, query }) { + const router = lookup.vpcRouter({ ...path, ...query }) + if (body.name) { + // Error if changing the router name and that router name already exists + if (body.name !== router.name) { + errIfExists(db.vpcRouters, { + vpc_id: router.vpc_id, + name: body.name, + }) + } + router.name = body.name + } + updateDesc(router, body) + return router + }, + vpcRouterDelete({ path, query }) { + const router = lookup.vpcRouter({ ...path, ...query }) + db.vpcRouters = db.vpcRouters.filter((r) => r.id !== router.id) + return 204 + }, + vpcRouterRouteList: ({ query }) => { + const { project, router, vpc } = query + const vpcRouter = lookup.vpcRouter({ project, router, vpc }) + const routes = db.vpcRouterRoutes.filter((r) => r.vpc_router_id === vpcRouter.id) + return paginated(query, routes) + }, + vpcRouterRouteCreate({ body, query }) { + const vpcRouter = lookup.vpcRouter(query) + errIfExists(db.vpcRouterRoutes, { vpc_router_id: vpcRouter.id, name: body.name }) + const newRoute: Json = { + id: uuid(), + vpc_router_id: vpcRouter.id, + kind: 'custom', + ...body, + ...getTimestamps(), + } + db.vpcRouterRoutes.push(newRoute) + return json(newRoute, { status: 201 }) + }, + vpcRouterRouteView: ({ path, query }) => lookup.vpcRouterRoute({ ...path, ...query }), + vpcRouterRouteUpdate({ body, path, query }) { + const route = lookup.vpcRouterRoute({ ...path, ...query }) + if (body.name) { + // Error if changing the route name and that route name already exists + if (body.name !== route.name) { + errIfExists(db.vpcRouterRoutes, { + vpc_router_id: route.vpc_router_id, + name: body.name, + }) + } + route.name = body.name + } + updateDesc(route, body) + if (body.destination) { + route.destination = body.destination + } + if (body.target) { + route.target = body.target + } + return route + }, + vpcRouterRouteDelete: ({ path, query }) => { + const route = lookup.vpcRouterRoute({ ...path, ...query }) + db.vpcRouterRoutes = db.vpcRouterRoutes.filter((r) => r.id !== route.id) + return 204 + }, vpcSubnetList({ query }) { const vpc = lookup.vpc(query) const subnets = db.vpcSubnets.filter((s) => s.vpc_id === vpc.id) @@ -1373,14 +1459,4 @@ export const handlers = makeHandlers({ timeseriesSchemaList: NotImplemented, userBuiltinList: NotImplemented, userBuiltinView: NotImplemented, - vpcRouterCreate: NotImplemented, - vpcRouterDelete: NotImplemented, - vpcRouterList: NotImplemented, - vpcRouterRouteCreate: NotImplemented, - vpcRouterRouteDelete: NotImplemented, - vpcRouterRouteList: NotImplemented, - vpcRouterRouteUpdate: NotImplemented, - vpcRouterRouteView: NotImplemented, - vpcRouterUpdate: NotImplemented, - vpcRouterView: NotImplemented, }) diff --git a/mock-api/vpc.ts b/mock-api/vpc.ts index bc338dae74..7e2670c9de 100644 --- a/mock-api/vpc.ts +++ b/mock-api/vpc.ts @@ -8,7 +8,7 @@ import { v4 as uuid } from 'uuid' -import type { Vpc, VpcFirewallRule, VpcSubnet } from '@oxide/api' +import type { RouterRoute, Vpc, VpcFirewallRule, VpcRouter, VpcSubnet } from '@oxide/api' import type { Json } from './json-type' import { project, project2 } from './project' @@ -45,6 +45,97 @@ export const vpc2: Json = { export const vpcs: Json = [vpc, vpc2] +export const defaultRouter: Json = { + id: 'fc59fb4d-baad-44a8-b152-9a3c27ae8aa1', + name: 'mock-system-router', + description: 'a fake router', + time_created: new Date(2024, 0, 1).toISOString(), + time_modified: new Date(2024, 0, 2).toISOString(), + vpc_id: vpc.id, + kind: 'system', +} + +export const customRouter: Json = { + id: '7ffc1613-8492-42f1-894b-9ef5c9ba2507', + name: 'mock-custom-router', + description: 'a fake custom router', + time_created: new Date(2024, 1, 1).toISOString(), + time_modified: new Date(2024, 1, 2).toISOString(), + vpc_id: vpc.id, + kind: 'custom', +} + +export const vpcRouters: Json = [defaultRouter, customRouter] + +const routeBase = { + time_created: '2024-07-11T17:46:21.161086Z', + time_modified: '2024-07-11T17:46:21.161086Z', + vpc_router_id: defaultRouter.id, +} + +export const routerRoutes: Json> = [ + { + ...routeBase, + id: '51e50342-790f-4efb-8518-10bf01279514', + name: 'default', + description: "VPC Subnet route for 'default'", + kind: 'vpc_subnet', + target: { + type: 'subnet', + value: 'default', + }, + destination: { + type: 'subnet', + value: 'default', + }, + }, + { + ...routeBase, + id: '4c98cd3b-37be-4754-954f-ca960f7a5c3f', + name: 'default-v4', + description: 'The default route of a vpc', + kind: 'default', + target: { + type: 'internet_gateway', + value: 'outbound', + }, + destination: { + type: 'ip_net', + value: '192.168.1.0/24', + }, + }, + { + ...routeBase, + id: '83ee96a3-e418-47fd-912e-e5b22c6a29c6', + name: 'default-v6', + description: 'The default route of a vpc', + kind: 'default', + target: { + type: 'internet_gateway', + value: 'outbound', + }, + destination: { + type: 'ip_net', + value: '2001:db8:abcd:12::/64', + }, + }, + { + ...routeBase, + vpc_router_id: customRouter.id, + id: '51e50342-790f-4efb-8518-10bf01279515', + name: 'drop-local', + description: 'Drop all local traffic', + kind: 'custom', + destination: { + type: 'ip', + value: '192.168.1.1', + }, + target: { + type: 'drop', + }, + }, +] + export const vpcSubnet: Json = { // this is supposed to be flattened into the top level. will fix in API id: 'd12bf934-d2bf-40e9-8596-bb42a7793749', diff --git a/test/e2e/error-pages.e2e.ts b/test/e2e/error-pages.e2e.ts index 26f6a5982e..d697e5cf5b 100644 --- a/test/e2e/error-pages.e2e.ts +++ b/test/e2e/error-pages.e2e.ts @@ -31,7 +31,7 @@ test('Shows something went wrong page on other errors', async ({ page }) => { // but we do see it in the browser console const error = - 'Invariant failed: Expected query to be prefetched. Key: ["projectView",{"path":{"project":"error-503"}}]' + 'Expected query to be prefetched.\nKey: ["projectView",{"path":{"project":"error-503"}}]' expect(errors.some((e) => e.message.includes(error))).toBeTruthy() // test clicking sign out diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index d34f579ade..3e1b5f7f63 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -7,7 +7,7 @@ */ import { expect, test } from '@playwright/test' -import { expectRowVisible } from './utils' +import { clickRowAction, expectRowVisible } from './utils' test('can nav to VpcPage from /', async ({ page }) => { await page.goto('/') @@ -47,7 +47,7 @@ test('can create and delete subnet', async ({ page }) => { // only one row in table, the default mock-subnet const rows = page.locator('tbody >> tr') await expect(rows).toHaveCount(1) - await expect(rows.nth(0).locator('text="mock-subnet"')).toBeVisible() + await expect(rows.nth(0).getByText('mock-subnet')).toBeVisible() // open modal, fill out form, submit await page.click('text=New subnet') @@ -57,11 +57,11 @@ test('can create and delete subnet', async ({ page }) => { await expect(rows).toHaveCount(2) - await expect(rows.nth(0).locator('text="mock-subnet"')).toBeVisible() - await expect(rows.nth(0).locator('text="10.1.1.1/24"')).toBeVisible() + await expect(rows.nth(0).getByText('mock-subnet')).toBeVisible() + await expect(rows.nth(0).getByText('10.1.1.1/24')).toBeVisible() - await expect(rows.nth(1).locator('text="mock-subnet-2"')).toBeVisible() - await expect(rows.nth(1).locator('text="10.1.1.2/24"')).toBeVisible() + await expect(rows.nth(1).getByText('mock-subnet-2')).toBeVisible() + await expect(rows.nth(1).getByText('10.1.1.2/24')).toBeVisible() // click more button on row to get menu, then click Delete await page @@ -74,3 +74,101 @@ test('can create and delete subnet', async ({ page }) => { await expect(rows).toHaveCount(1) }) + +test('can create, update, and delete Router', async ({ page }) => { + // load the VPC page for mock-vpc, to the firewall-rules tab + await page.goto('/projects/mock-project/vpcs/mock-vpc') + await page.getByRole('tab', { name: 'Routers' }).click() + + // expect to see the list of routers, including mock-system-router and mock-custom-router + const table = page.getByRole('table') + const tbody = table.locator('tbody') + const rows = tbody.locator('tr') + await expect(rows).toHaveCount(2) + await expectRowVisible(table, { name: 'mock-system-router' }) + await expectRowVisible(table, { name: 'mock-custom-router' }) + + // delete mock-custom-router + await clickRowAction(page, 'mock-custom-router', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(rows).toHaveCount(1) + await expect(rows.getByText('mock-custom-router')).toBeHidden() + + // create a new router + await page.click('text=New router') + await page.fill('input[name=name]', 'mock-custom-router-2') + await page.click('button:has-text("Create router")') + await expect(rows).toHaveCount(2) + await expectRowVisible(table, { name: 'mock-custom-router-2' }) + + // click on mock-system-router to go to the router detail page + await page.getByText('mock-system-router').click() + await expect(page).toHaveURL( + '/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router' + ) +}) + +test('can’t create or delete Routes on system routers', async ({ page }) => { + // load the router + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-system-router') + + // verify that the "new route" link isn't present, since users can't add routes to system routers + await expect(page.getByRole('link', { name: 'New route' })).toBeHidden() + + // expect to see table of routes + const table = page.getByRole('table') + const routeRows = table.locator('tbody >> tr') + await expect(routeRows).toHaveCount(3) + await expectRowVisible(table, { Name: 'default' }) + await expectRowVisible(table, { Name: 'default-v4' }) + await expectRowVisible(table, { Name: 'default-v6' }) + await routeRows.first().getByRole('button', { name: 'Row actions' }).click() + // can't delete default routes + await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeDisabled() +}) + +test('can create, update, and delete Route', async ({ page }) => { + // go to the custom-router-2 page + await page.goto('/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router') + const table = page.getByRole('table') + const routeRows = table.locator('tbody >> tr') + await expectRowVisible(table, { Name: 'drop-local' }) + + // create a new route + await page.click('text=New route') + await page.getByRole('textbox', { name: 'name' }).fill('new-route') + await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.0') + + // we'll set the target in a second, but first verify that selecting internet gateway disables the value + await page.getByRole('button', { name: 'Target type' }).click() + await page.getByRole('option', { name: 'Internet gateway' }).click() + await expect(page.getByRole('textbox', { name: 'Target value' })).toBeDisabled() + await expect(page.getByRole('textbox', { name: 'Target value' })).toHaveValue('outbound') + await page.getByRole('button', { name: 'Target type' }).click() + await page.getByRole('option', { name: 'IP' }).click() + await page.getByRole('textbox', { name: 'Target value' }).fill('1.1.1.1') + await page.getByRole('button', { name: 'Create route' }).click() + await expect(routeRows).toHaveCount(2) + await expectRowVisible(table, { + Name: 'new-route', + Destination: 'IP0.0.0.0', + Target: 'IP1.1.1.1', + }) + + // update the route by clicking the edit button + await clickRowAction(page, 'new-route', 'Edit') + await page.getByRole('textbox', { name: 'Destination value' }).fill('0.0.0.1') + await page.getByRole('button', { name: 'Update route' }).click() + await expect(routeRows).toHaveCount(2) + await expectRowVisible(table, { + Name: 'new-route', + Destination: 'IP0.0.0.1', + Target: 'IP1.1.1.1', + }) + + // delete the route + await clickRowAction(page, 'new-route', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(routeRows).toHaveCount(1) + await expect(page.getByRole('row', { name: 'new-route' })).toBeHidden() +})