()
+
+export default function InternetGatewayPage() {
+ const { project, vpc, gateway } = useInternetGatewaySelector()
+ const navigate = useNavigate()
+ const { data: gatewayData } = usePrefetchedQuery(gatewayView({ project, vpc, gateway }))
+ const { data: gatewayIpPools } = usePrefetchedQuery(
+ gatewayIpPoolList({ project, vpc, gateway }).optionsFn()
+ )
+ const { data: gatewayIpAddresses } = usePrefetchedQuery(
+ gatewayIpAddressList({ project, vpc, gateway }).optionsFn()
+ )
+ const matchingRoutes = useGatewayRoutes({ project, vpc, gateway })
+
+ const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, {
+ onSuccess() {
+ navigate(pb.vpcInternetGateways({ project, vpc }))
+ queryClient.invalidateEndpoint('internetGatewayList')
+ // prettier-ignore
+ addToast(<>Internet gateway {gateway} deleted>)
+ },
+ })
+
+ const { mutateAsync: detachPool } = useApiMutation(api.internetGatewayIpPoolDelete, {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayIpPoolList')
+ // prettier-ignore
+ addToast(<>IP pool {variables.path.pool} detached>)
+ },
+ })
+
+ const { mutateAsync: detachAddress } = useApiMutation(
+ api.internetGatewayIpAddressDelete,
+ {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayIpAddressList')
+ // prettier-ignore
+ addToast(<>IP address {variables.path.address} detached>)
+ },
+ }
+ )
+
+ const makePoolActions = useCallback(
+ (pool: InternetGatewayIpPool): MenuAction[] => [
+ {
+ label: 'Detach',
+ className: 'destructive',
+ onActivate: () =>
+ confirmAction({
+ doAction: () =>
+ detachPool({
+ path: { pool: pool.name },
+ query: { project, vpc, gateway },
+ }),
+ errorTitle: 'Could not detach IP pool',
+ modalTitle: 'Detach IP pool',
+ modalContent: (
+
+ Are you sure you want to detach IP pool {pool.name} from gateway{' '}
+ {gateway} ?
+
+ ),
+ actionType: 'danger',
+ }),
+ },
+ ],
+ [detachPool, project, vpc, gateway]
+ )
+
+ const makeAddressActions = useCallback(
+ (address: InternetGatewayIpAddress): MenuAction[] => [
+ {
+ label: 'Detach',
+ className: 'destructive',
+ onActivate: () =>
+ confirmAction({
+ doAction: () =>
+ detachAddress({
+ path: { address: address.name },
+ query: { project, vpc, gateway },
+ }),
+ errorTitle: 'Could not detach IP address',
+ modalTitle: 'Detach IP address',
+ modalContent: (
+
+ Are you sure you want to detach IP address {address.name} from
+ gateway {gateway} ?
+
+ ),
+ actionType: 'danger',
+ }),
+ },
+ ],
+ [detachAddress, project, vpc, gateway]
+ )
+
+ const poolsTable = useReactTable({
+ columns: useColsWithActions(
+ [
+ poolColHelper.accessor('name', {}),
+ poolColHelper.accessor('description', Columns.description),
+ poolColHelper.accessor('ipPoolId', {
+ header: 'IP pool',
+ cell: (info) => ,
+ }),
+ poolColHelper.accessor('timeCreated', Columns.timeCreated),
+ ],
+ makePoolActions
+ ),
+ data: gatewayIpPools.items,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const addressesTable = useReactTable({
+ columns: useColsWithActions(
+ [
+ addressColHelper.accessor('name', {}),
+ addressColHelper.accessor('description', Columns.description),
+ addressColHelper.accessor('address', {
+ cell: (info) => ,
+ }),
+ addressColHelper.accessor('timeCreated', Columns.timeCreated),
+ ],
+ makeAddressActions
+ ),
+ data: gatewayIpAddresses.items,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const routesData = useMemo(
+ () => (matchingRoutes || []).map(([router, route]) => ({ router, route: route.name })),
+ [matchingRoutes]
+ )
+ const routesTable = useReactTable({
+ columns: useMemo(
+ () => [
+ routeColHelper.accessor('router', {
+ header: 'Router',
+ cell: (info) => (
+
+ {info.getValue()}
+
+ ),
+ }),
+ routeColHelper.accessor('route', { header: 'Route' }),
+ ],
+ [project, vpc]
+ ),
+ data: routesData,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ useQuickActions(
+ () => [
+ {
+ value: 'Attach IP pool',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewayIpPoolsNew({ project, vpc, gateway }),
+ },
+ {
+ value: 'Attach IP address',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewayIpAddressesNew({ project, vpc, gateway }),
+ },
+ ],
+ [project, vpc, gateway]
+ )
+
+ return (
+ <>
+
+ }>{gateway}
+
+ }
+ summary="An internet gateway connects a VPC to the internet, using addresses from an attached IP pool or an attached IP address."
+ links={[docLinks.gateways]}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Attach IP pool
+
+
+
+ {gatewayIpPools.items.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ Attach IP address
+
+
+
+ {gatewayIpAddresses.items.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ {routesData.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx
index bbb095afa..5e8c30007 100644
--- a/app/pages/project/vpcs/VpcGatewaysTab.tsx
+++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx
@@ -8,17 +8,23 @@
import { useQuery } from '@tanstack/react-query'
import { createColumnHelper } from '@tanstack/react-table'
-import { useMemo } from 'react'
+import { useCallback, useMemo } from 'react'
import { Outlet, type LoaderFunctionArgs } from 'react-router'
-import { api, getListQFn, queryClient, type InternetGateway } from '~/api'
+import { api, getListQFn, queryClient, useApiMutation, type InternetGateway } from '~/api'
+import { HL } from '~/components/HL'
+import { ListPlusOverflow } from '~/components/ListPlusCell'
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
+import { useQuickActions } from '~/hooks/use-quick-actions'
+import { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell'
import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell'
+import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
import { CopyableIp } from '~/ui/lib/CopyableIp'
+import { CreateLink } from '~/ui/lib/CreateButton'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { TipIcon } from '~/ui/lib/TipIcon'
import { ALL_ISH } from '~/util/consts'
@@ -32,6 +38,7 @@ import {
routerList,
useGatewayRoutes,
} from './gateway-data'
+import { confirmDeleteGateway } from './gateway-delete'
export const handle = { crumb: 'Internet Gateways' }
@@ -43,14 +50,33 @@ const projectIpPoolList = getListQFn(api.ipPoolList, {
const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => {
const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn())
- if (!addresses || addresses.items.length < 1) return
- return
+ const address = addresses?.items[0]
+ if (!address) return
+ return
+}
+
+// plain pool name for the +N tooltip, where IpPoolCell's interactive button
+// wouldn't be usable
+const IpPoolName = ({ ipPoolId }: { ipPoolId: string }) => {
+ const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId))
+ if (!result || result.type === 'error') return null
+ return {result.data.name}
}
const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => {
- const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
- if (!gateways || gateways.items.length < 1) return
- return
+ const { data: pools } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
+ const [first, ...rest] = pools?.items || []
+ if (!first) return
+ return (
+
+
+
+ {rest.map((pool) => (
+
+ ))}
+
+
+ )
}
const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => {
@@ -111,12 +137,36 @@ export default function VpcInternetGatewaysTab() {
)
- const columns = useMemo(
+ const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayList')
+ // prettier-ignore
+ addToast(<>Internet gateway {variables.path.gateway} deleted>)
+ },
+ })
+
+ const makeActions = useCallback(
+ (gateway: InternetGateway): MenuAction[] => [
+ {
+ label: 'Delete',
+ className: 'destructive',
+ onActivate: confirmDeleteGateway({
+ project,
+ vpc,
+ gateway: gateway.name,
+ deleteGateway,
+ }),
+ },
+ ],
+ [deleteGateway, project, vpc]
+ )
+
+ const staticColumns = useMemo(
() => [
colHelper.accessor('name', {
cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })),
@@ -151,14 +201,32 @@ export default function VpcInternetGatewaysTab() {
[project, vpc]
)
+ const columns = useColsWithActions(staticColumns, makeActions)
+
const { table } = useQueryTable({
query: gatewayList({ project, vpc }),
columns,
emptyState,
})
+ useQuickActions(
+ () => [
+ {
+ value: 'New internet gateway',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewaysNew({ project, vpc }),
+ },
+ ],
+ [project, vpc]
+ )
+
return (
<>
+
+
+ New internet gateway
+
+
{table}
>
diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts
index bff27bfce..99ab700e6 100644
--- a/app/pages/project/vpcs/gateway-data.ts
+++ b/app/pages/project/vpcs/gateway-data.ts
@@ -35,17 +35,24 @@ export function useGatewayRoutes({ project, vpc, gateway }: PP.VpcInternetGatewa
const { data: routers } = usePrefetchedQuery(routerList({ project, vpc }).optionsFn())
const routerNames = routers.items.map((r) => r.name)
- const routesQueries = useQueries({
+ return useQueries({
queries: routerNames.map((router) => routeList({ project, vpc, router }).optionsFn()),
+ // combine's result is structurally shared by React Query, so the returned
+ // array is referentially stable across renders — required by consumers
+ // that use it as table data or in dep arrays
+ combine: (results) => {
+ // loading. should never happen because of prefetches
+ if (!results.every((q) => !!q.data)) return null
+ return R.pipe(
+ R.zip(
+ routerNames,
+ results.map((q) => q.data.items)
+ ),
+ R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)),
+ R.filter(
+ ([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway
+ )
+ )
+ },
})
- const loadedRoutesLists = routesQueries.filter((q) => !!q.data).map((q) => q.data.items)
-
- // loading. should never happen because of prefetches
- if (loadedRoutesLists.length < routers.items.length) return null
-
- return R.pipe(
- R.zip(routerNames, loadedRoutesLists),
- R.flatMap(([router, routes]) => routes.map((route) => [router, route] as const)),
- R.filter(([_, r]) => r.target.type === 'internet_gateway' && r.target.value === gateway)
- )
}
diff --git a/app/pages/project/vpcs/gateway-delete.tsx b/app/pages/project/vpcs/gateway-delete.tsx
new file mode 100644
index 000000000..a70c20583
--- /dev/null
+++ b/app/pages/project/vpcs/gateway-delete.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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 { useId, useState } from 'react'
+
+import {
+ queryClient,
+ type api,
+ type InternetGatewayIpAddressResultsPage,
+ type InternetGatewayIpPoolResultsPage,
+} from '~/api'
+import { RadioCard } from '~/components/RadioCard'
+import { confirmDelete } from '~/stores/confirm-delete'
+import type * as PP from '~/util/path-params'
+
+import { gatewayIpAddressList, gatewayIpPoolList } from './gateway-data'
+
+function attachmentsNotice(numPools: number, numAddresses: number) {
+ const parts = []
+ if (numPools > 0) parts.push(`${numPools} IP ${numPools === 1 ? 'pool' : 'pools'}`)
+ if (numAddresses > 0) {
+ parts.push(`${numAddresses} IP ${numAddresses === 1 ? 'address' : 'addresses'}`)
+ }
+ if (parts.length === 0) return undefined
+ return `${parts.join(' and ')} must be detached first`
+}
+
+function GatewayCascadeChoice({
+ numPools,
+ numAddresses,
+ onChange,
+}: {
+ numPools: number
+ numAddresses: number
+ onChange: (cascade: boolean) => void
+}) {
+ const hasAttachments = numPools > 0 || numAddresses > 0
+ const [cascade, setCascade] = useState(hasAttachments)
+ const name = useId()
+
+ function select(value: boolean) {
+ setCascade(value)
+ onChange(value)
+ }
+
+ return (
+
+ select(false)}
+ disabled={hasAttachments}
+ label="Delete gateway only"
+ description="The gateway is deleted, attached resources and routes are untouched"
+ notice={attachmentsNotice(numPools, numAddresses)}
+ />
+ select(true)}
+ label="Delete gateway and detach resources"
+ description="IP pools and IP addresses are detached and routes targeting this gateway are deleted"
+ />
+
+ )
+}
+
+/**
+ * Shared "delete internet gateway" confirmation, used by both the gateways
+ * table and the gateway detail page. Returns a callback suitable for
+ * `onActivate`/`onSelect`.
+ */
+export function confirmDeleteGateway({
+ project,
+ vpc,
+ gateway,
+ deleteGateway,
+}: PP.VpcInternetGateway & {
+ deleteGateway: (
+ params: Parameters[0]
+ ) => Promise
+}) {
+ return () => {
+ // already fetched by the pages that list a gateway's pools/addresses, so
+ // this reads from cache rather than triggering a new request
+ const pools = queryClient.getQueryData(
+ gatewayIpPoolList({ project, vpc, gateway }).optionsFn().queryKey
+ )
+ const addresses = queryClient.getQueryData(
+ gatewayIpAddressList({ project, vpc, gateway }).optionsFn().queryKey
+ )
+ const numPools = pools?.items.length ?? 0
+ const numAddresses = addresses?.items.length ?? 0
+
+ // when there are attachments, the non-cascading option is disabled, so
+ // cascade starts out selected
+ let cascade = numPools > 0 || numAddresses > 0
+ confirmDelete({
+ doDelete: () =>
+ deleteGateway({ path: { gateway }, query: { project, vpc, cascade } }),
+ label: gateway,
+ resourceKind: 'internet gateway',
+ extraContent: (
+ {
+ cascade = value
+ }}
+ />
+ ),
+ })()
+ }
+}
diff --git a/app/pages/project/vpcs/internet-gateway-edit.tsx b/app/pages/project/vpcs/internet-gateway-edit.tsx
deleted file mode 100644
index 5d5ac415d..000000000
--- a/app/pages/project/vpcs/internet-gateway-edit.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-/*
- * This Source Code Form is subject to the terms of the Mozilla Public
- * License, v. 2.0. If a copy of the MPL was not distributed with this
- * file, you can obtain one at https://mozilla.org/MPL/2.0/.
- *
- * Copyright Oxide Computer Company
- */
-
-import { useQuery } from '@tanstack/react-query'
-import { Link, useNavigate, type LoaderFunctionArgs } from 'react-router'
-
-import { Gateway16Icon } from '@oxide/design-system/icons/react'
-
-import { api, q, queryClient, usePrefetchedQuery } from '~/api'
-import { ReadOnlySideModalForm } from '~/components/form/ReadOnlySideModalForm'
-import { titleCrumb } from '~/hooks/use-crumbs'
-import { getInternetGatewaySelector, useInternetGatewaySelector } from '~/hooks/use-params'
-import { IpPoolCell } from '~/table/cells/IpPoolCell'
-import { CopyableIp } from '~/ui/lib/CopyableIp'
-import { FormDivider } from '~/ui/lib/Divider'
-import { Message } from '~/ui/lib/Message'
-import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
-import { PropertiesTable } from '~/ui/lib/PropertiesTable'
-import { ResourceLabel, SideModal } from '~/ui/lib/SideModal'
-import { Table } from '~/ui/lib/Table'
-import { docLinks } from '~/util/links'
-import { pb } from '~/util/path-builder'
-import type * as PP from '~/util/path-params'
-
-import {
- gatewayIpAddressList,
- gatewayIpPoolList,
- routeList,
- routerList,
- useGatewayRoutes,
-} from './gateway-data'
-
-export const handle = titleCrumb('Edit Internet Gateway')
-
-const RoutesEmpty = () => (
-
-
- No VPC router routes target this gateway.
-
-
-)
-
-function RouteRows({ project, vpc, gateway }: PP.VpcInternetGateway) {
- const matchingRoutes = useGatewayRoutes({ project, vpc, gateway })
-
- if (!matchingRoutes) return null
- if (matchingRoutes.length === 0) return
-
- return matchingRoutes.map(([router, route]) => (
-
-
-
- {router}
-
-
- {route.name}
-
- ))
-}
-
-export async function clientLoader({ params }: LoaderFunctionArgs) {
- const { project, vpc, gateway } = getInternetGatewaySelector(params)
- await Promise.all([
- queryClient.prefetchQuery(
- q(api.internetGatewayView, {
- query: { project, vpc },
- path: { gateway },
- })
- ),
- queryClient.prefetchQuery(gatewayIpPoolList({ project, vpc, gateway }).optionsFn()),
- queryClient.prefetchQuery(gatewayIpAddressList({ project, vpc, gateway }).optionsFn()),
- ...(await queryClient.fetchQuery(routerList({ project, vpc }).optionsFn())).items.map(
- (router) =>
- queryClient.prefetchQuery(
- routeList({ project, vpc, router: router.name }).optionsFn()
- )
- ),
- ] satisfies Promise[])
- return null
-}
-
-export default function EditInternetGatewayForm() {
- const navigate = useNavigate()
- const { project, vpc, gateway } = useInternetGatewaySelector()
- const onDismiss = () => navigate(pb.vpcInternetGateways({ project, vpc }))
- const { data: internetGateway } = usePrefetchedQuery(
- q(api.internetGatewayView, {
- query: { project, vpc },
- path: { gateway },
- })
- )
- const { data: { items: gatewayIpPools } = {} } = useQuery(
- gatewayIpPoolList({ project, vpc, gateway }).optionsFn()
- )
- const { data: { items: gatewayIpAddresses } = {} } = useQuery(
- gatewayIpAddressList({ project, vpc, gateway }).optionsFn()
- )
-
- const hasAttachedPool = gatewayIpPools && gatewayIpPools.length > 0
-
- return (
-
- {internetGateway.name}
-
- }
- >
-
-
- {internetGateway.name}
-
-
-
-
-
-
-
- Internet gateway IP address
- {gatewayIpAddresses && gatewayIpAddresses.length > 1 ? 'es' : ''}
-
- {gatewayIpAddresses && gatewayIpAddresses.length > 0 ? (
- gatewayIpAddresses.map((gatewayIpAddress) => (
-
-
- {gatewayIpAddress.name}
-
-
-
-
-
-
- ))
- ) : (
-
- {'This internet gateway does not have any IP addresses attached. '}
- {hasAttachedPool
- ? 'It will use an address from the attached IP pool.'
- : 'Attach an IP pool or IP address via the CLI or API.'}
-
- )}
-
-
-
-
-
-
- Internet gateway IP pool
- {gatewayIpPools && gatewayIpPools.length > 1 ? 's' : ''}
-
- {hasAttachedPool ? (
- gatewayIpPools.map((gatewayIpPool) => (
-
- {gatewayIpPool.name}
-
-
-
-
-
- ))
- ) : (
-
- This internet gateway does not have any IP pools attached.
-
- )}
-
-
-
-
-
-
Routes targeting this gateway
-
-
-
- Router
- Route
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/app/routes.tsx b/app/routes.tsx
index 2fdaadc22..8dff14d7a 100644
--- a/app/routes.tsx
+++ b/app/routes.tsx
@@ -485,14 +485,12 @@ export const routes = createRoutesFromElements(
/>
import('./pages/project/vpcs/VpcGatewaysTab').then(convert)}
>
+
- import('./pages/project/vpcs/internet-gateway-edit').then(convert)
- }
+ path="internet-gateways-new"
+ lazy={() => import('./forms/internet-gateway-create').then(convert)}
/>
@@ -518,6 +516,28 @@ export const routes = createRoutesFromElements(
+
+
+ import('./pages/project/vpcs/InternetGatewayPage').then(convert)
+ }
+ >
+
+
+ import('./forms/internet-gateway-ip-pool-create').then(convert)
+ }
+ />
+
+ import('./forms/internet-gateway-ip-address-create').then(convert)
+ }
+ />
+
+
(
+
+
+ {/* the dot in the middle. hide by default, use peer-checked to show if checked */}
+
+
+)
+
export const Radio = ({ children, className, ...inputProps }: RadioProps) => (
{/* Center the 1rem (h-4) radio button with the first line of text.
1lh is the line height, so (1lh - 1rem) / 2 is the top offset
that vertically centers the indicator within that line. */}
-
-
- {/* the dot in the middle. hide by default, use peer-checked to show if checked */}
-
-
+
{children && {children} }
diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap
index 300fee583..e7077499f 100644
--- a/app/util/__snapshots__/path-builder.spec.ts.snap
+++ b/app/util/__snapshots__/path-builder.spec.ts.snap
@@ -1052,12 +1052,68 @@ exports[`breadcrumbs 2`] = `
},
{
"label": "v",
- "path": "/projects/p/vpcs/v/firewall-rules",
+ "path": "/projects/p/vpcs/v",
},
{
"label": "Internet Gateways",
"path": "/projects/p/vpcs/v/internet-gateways",
},
+ {
+ "label": "g",
+ "path": "/projects/p/vpcs/v/internet-gateways/g",
+ },
+ ],
+ "vpcInternetGatewayIpAddressesNew (/projects/p/vpcs/v/internet-gateways/g/ip-addresses-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v",
+ },
+ {
+ "label": "Internet Gateways",
+ "path": "/projects/p/vpcs/v/internet-gateways",
+ },
+ {
+ "label": "g",
+ "path": "/projects/p/vpcs/v/internet-gateways/g",
+ },
+ ],
+ "vpcInternetGatewayIpPoolsNew (/projects/p/vpcs/v/internet-gateways/g/ip-pools-new)": [
+ {
+ "label": "Projects",
+ "path": "/projects",
+ },
+ {
+ "label": "p",
+ "path": "/projects/p/instances",
+ },
+ {
+ "label": "VPCs",
+ "path": "/projects/p/vpcs",
+ },
+ {
+ "label": "v",
+ "path": "/projects/p/vpcs/v",
+ },
+ {
+ "label": "Internet Gateways",
+ "path": "/projects/p/vpcs/v/internet-gateways",
+ },
+ {
+ "label": "g",
+ "path": "/projects/p/vpcs/v/internet-gateways/g",
+ },
],
"vpcInternetGateways (/projects/p/vpcs/v/internet-gateways)": [
{
@@ -1078,7 +1134,29 @@ exports[`breadcrumbs 2`] = `
},
{
"label": "Internet Gateways",
- "path": "/projects/p/vpcs/v/internet-gateways",
+ "path": "/projects/p/vpcs/v/",
+ },
+ ],
+ "vpcInternetGatewaysNew (/projects/p/vpcs/v/internet-gateways-new)": [
+ {
+ "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/",
},
],
"vpcRouter (/projects/p/vpcs/v/routers/r)": [
diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts
index 9fc90181e..2f305c01b 100644
--- a/app/util/path-builder.spec.ts
+++ b/app/util/path-builder.spec.ts
@@ -123,7 +123,10 @@ test('path builder', () => {
"vpcFirewallRules": "/projects/p/vpcs/v/firewall-rules",
"vpcFirewallRulesNew": "/projects/p/vpcs/v/firewall-rules-new",
"vpcInternetGateway": "/projects/p/vpcs/v/internet-gateways/g",
+ "vpcInternetGatewayIpAddressesNew": "/projects/p/vpcs/v/internet-gateways/g/ip-addresses-new",
+ "vpcInternetGatewayIpPoolsNew": "/projects/p/vpcs/v/internet-gateways/g/ip-pools-new",
"vpcInternetGateways": "/projects/p/vpcs/v/internet-gateways",
+ "vpcInternetGatewaysNew": "/projects/p/vpcs/v/internet-gateways-new",
"vpcRouter": "/projects/p/vpcs/v/routers/r",
"vpcRouterEdit": "/projects/p/vpcs/v/routers/r/edit",
"vpcRouterRouteEdit": "/projects/p/vpcs/v/routers/r/routes/rr/edit",
diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts
index e09ad45aa..1d4fe7183 100644
--- a/app/util/path-builder.ts
+++ b/app/util/path-builder.ts
@@ -88,10 +88,14 @@ export const pb = {
`${pb.vpcSubnets(params)}/${params.subnet}/edit`,
vpcInternetGateways: (params: PP.Vpc) => `${vpcBase(params)}/internet-gateways`,
+ vpcInternetGatewaysNew: (params: PP.Vpc) => `${vpcBase(params)}/internet-gateways-new`,
vpcInternetGateway: (params: PP.VpcInternetGateway) =>
`${pb.vpcInternetGateways(params)}/${params.gateway}`,
- // vpcInternetGatewaysNew: (params: Vpc) => `${vpcBase(params)}/internet-gateways-new`,
- //
+ vpcInternetGatewayIpPoolsNew: (params: PP.VpcInternetGateway) =>
+ `${pb.vpcInternetGateway(params)}/ip-pools-new`,
+ vpcInternetGatewayIpAddressesNew: (params: PP.VpcInternetGateway) =>
+ `${pb.vpcInternetGateway(params)}/ip-addresses-new`,
+
externalSubnets: (params: PP.Project) => `${projectBase(params)}/external-subnets`,
externalSubnetsNew: (params: PP.Project) => `${projectBase(params)}/external-subnets-new`,
externalSubnetEdit: (params: PP.ExternalSubnet) =>
diff --git a/mock-api/internet-gateway.ts b/mock-api/internet-gateway.ts
index 93c1cfeb1..bc9dc6c89 100644
--- a/mock-api/internet-gateway.ts
+++ b/mock-api/internet-gateway.ts
@@ -70,7 +70,7 @@ export const internetGatewayIpAddresses: Json[] = [
]
const internetGatewayIpPool1: Json = {
- id: '1d5e5a1f-0b2b-4d5b-8b9d-2d4b3e0c6gb9',
+ id: '48bab245-98a4-402e-8689-13af5f7d5411',
name: 'internet-gateway-pool-1',
description: 'An IP pool for an internet gateway',
internet_gateway_id: internetGateway1.id,
@@ -81,7 +81,7 @@ const internetGatewayIpPool1: Json = {
const internetGatewayIpPool2: Json = {
id: 'd5e5a1f1-0b2b-4d5b-8b9d-2d4b3e0c6b9c',
- name: 'interent-gateway-pool-2',
+ name: 'internet-gateway-pool-2',
description: 'another IP pool for an internet gateway',
internet_gateway_id: internetGateway2.id,
ip_pool_id: ipPool2.id,
diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts
index 9986205ed..30eb21682 100644
--- a/mock-api/msw/db.ts
+++ b/mock-api/msw/db.ts
@@ -366,6 +366,25 @@ export const lookup = {
return ip
},
+ internetGatewayIpPool({
+ pool: id,
+ ...gatewaySelector
+ }: Sel.InternetGatewayIpPool): Json {
+ if (!id) throw notFoundErr('no IP pool specified')
+
+ if (isUuid(id)) {
+ ensureNoParentSelectors('IP pool', gatewaySelector)
+ return lookupById(db.internetGatewayIpPools, id)
+ }
+
+ const gateway = lookup.internetGateway(gatewaySelector)
+ const pool = db.internetGatewayIpPools.find(
+ (p) => p.internet_gateway_id === gateway.id && p.name === id
+ )
+ if (!pool) throw notFoundErr(`IP pool '${id}'`)
+
+ return pool
+ },
image({ image: id, project: projectId }: Sel.Image): Json {
if (!id) throw notFoundErr('no image specified')
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts
index 5f2b05637..d76ee8030 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -58,6 +58,7 @@ import {
handleOxqlMetrics,
internalError,
invalidRequest,
+ ipInAnyRange,
ipRangeLen,
mockFlags,
NotImplemented,
@@ -79,6 +80,33 @@ import {
// client camel-cases the keys and parses date fields. Inside the mock API everything
// is *JSON type.
+// Omicron matches routes on the serialized target `inetgw:` globally,
+// not scoped to the gateway's VPC, so we match on name globally too
+// https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L2030-L2038
+const routesTargetGateway = (gateway: Json) =>
+ db.vpcRouterRoutes.some(
+ (r) => r.target.type === 'internet_gateway' && r.target.value === gateway.name
+ )
+
+// external IPs (ephemeral, SNAT, attached floating) on instances with a NIC in
+// the given VPC, used for the internet gateway detach dependency checks
+const vpcInstanceExternalIps = (vpcId: string) => {
+ const instanceIds = new Set(
+ db.networkInterfaces.filter((n) => n.vpc_id === vpcId).map((n) => n.instance_id)
+ )
+ return [
+ ...db.ephemeralIps
+ .filter((e) => instanceIds.has(e.instance_id))
+ .map((e) => e.external_ip.ip),
+ ...db.snatIps
+ .filter((s) => instanceIds.has(s.instance_id))
+ .map((s) => s.external_ip.ip),
+ ...db.floatingIps
+ .filter((f) => f.instance_id && instanceIds.has(f.instance_id))
+ .map((f) => f.ip),
+ ]
+}
+
export const handlers = makeHandlers({
logout: () => 204,
ping: () => ({ status: 'ok' }),
@@ -1632,6 +1660,145 @@ export const handlers = makeHandlers({
return paginated(query, gateways)
},
internetGatewayView: ({ path, query }) => lookup.internetGateway({ ...path, ...query }),
+ internetGatewayCreate({ body, query }) {
+ const vpc = lookup.vpc(query)
+ errIfExists(db.internetGateways, { vpc_id: vpc.id, name: body.name })
+
+ const newGateway: Json = {
+ id: uuid(),
+ vpc_id: vpc.id,
+ ...body,
+ ...getTimestamps(),
+ }
+ db.internetGateways.push(newGateway)
+ return json(newGateway, { status: 201 })
+ },
+ internetGatewayDelete({ path, query }) {
+ const gateway = lookup.internetGateway({ ...path, ...query })
+
+ const hasPools = db.internetGatewayIpPools.some(
+ (p) => p.internet_gateway_id === gateway.id
+ )
+ const hasAddresses = db.internetGatewayIpAddresses.some(
+ (a) => a.internet_gateway_id === gateway.id
+ )
+ // without cascade, deletion fails if any IP pools or addresses are
+ // attached. Omicron checks pools first, so a gateway with both attached
+ // gets the pools message.
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L1645-L1711
+ if (!query.cascade && (hasPools || hasAddresses)) {
+ const attached = hasPools ? 'Ip pools' : 'Ip addresses'
+ throw invalidRequest(
+ `${attached} referencing this gateway exist. To perform a cascading delete set the cascade option`
+ )
+ }
+
+ db.internetGateways = db.internetGateways.filter((g) => g.id !== gateway.id)
+ db.internetGatewayIpPools = db.internetGatewayIpPools.filter(
+ (p) => p.internet_gateway_id !== gateway.id
+ )
+ db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter(
+ (a) => a.internet_gateway_id !== gateway.id
+ )
+ // only cascade deletes routes targeting the gateway (scoped to the VPC's
+ // routers); a plain delete leaves them dangling
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L1713-L1791
+ if (query.cascade) {
+ const routerIds = new Set(
+ db.vpcRouters.filter((r) => r.vpc_id === gateway.vpc_id).map((r) => r.id)
+ )
+ db.vpcRouterRoutes = db.vpcRouterRoutes.filter(
+ (r) =>
+ !(
+ routerIds.has(r.vpc_router_id) &&
+ r.target.type === 'internet_gateway' &&
+ r.target.value === gateway.name
+ )
+ )
+ }
+ return 204
+ },
+ internetGatewayIpPoolCreate({ body, query }) {
+ const gateway = lookup.internetGateway(query)
+ const ipPool = lookup.ipPool({ pool: body.ip_pool })
+
+ const newGatewayIpPool: Json = {
+ id: uuid(),
+ internet_gateway_id: gateway.id,
+ ip_pool_id: ipPool.id,
+ name: body.name,
+ description: body.description,
+ ...getTimestamps(),
+ }
+ db.internetGatewayIpPools.push(newGatewayIpPool)
+ return json(newGatewayIpPool, { status: 201 })
+ },
+ internetGatewayIpPoolDelete({ path, query }) {
+ const pool = lookup.internetGatewayIpPool({ ...path, ...query })
+ // without cascade, detach fails if routes target the parent gateway and an
+ // instance in the VPC has an external IP from the pool being detached.
+ // Cascade skips the check; it does not delete the routes.
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L2009-L2109
+ if (!query.cascade) {
+ const gateway = lookupById(db.internetGateways, pool.internet_gateway_id)
+ if (routesTargetGateway(gateway)) {
+ const ranges = db.ipPoolRanges
+ .filter((r) => r.ip_pool_id === pool.ip_pool_id)
+ .map((r) => r.range)
+ const dependent = vpcInstanceExternalIps(gateway.vpc_id).some((ip) =>
+ ipInAnyRange(ip, ranges)
+ )
+ if (dependent) {
+ throw invalidRequest(
+ 'VPC routes dependent on this IP pool. To perform a cascading delete set the cascade option'
+ )
+ }
+ }
+ }
+ db.internetGatewayIpPools = db.internetGatewayIpPools.filter((p) => p.id !== pool.id)
+ return 204
+ },
+ internetGatewayIpAddressCreate({ body, query }) {
+ const gateway = lookup.internetGateway(query)
+ // a gateway can have at most one IP address attached: the unique index is
+ // on internet_gateway_id alone
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/schema/crdb/dbinit.sql#L2314-L2317
+ if (db.internetGatewayIpAddresses.some((a) => a.internet_gateway_id === gateway.id)) {
+ throw alreadyExistsErr(`already exists: internet-gateway-ip-address "${body.name}"`)
+ }
+
+ const newGatewayIpAddress: Json = {
+ id: uuid(),
+ internet_gateway_id: gateway.id,
+ ...body,
+ ...getTimestamps(),
+ }
+ db.internetGatewayIpAddresses.push(newGatewayIpAddress)
+ return json(newGatewayIpAddress, { status: 201 })
+ },
+ internetGatewayIpAddressDelete({ path, query }) {
+ const address = lookup.internetGatewayIpAddress({ ...path, ...query })
+ // same dependency check as pool detach, but against this exact address.
+ // The error message says "IP pool" even here, matching Omicron.
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L2174-L2253
+ if (!query.cascade) {
+ const gateway = lookupById(db.internetGateways, address.internet_gateway_id)
+ if (routesTargetGateway(gateway)) {
+ const dependent = vpcInstanceExternalIps(gateway.vpc_id).some(
+ (ip) => ip === address.address
+ )
+ if (dependent) {
+ throw invalidRequest(
+ 'VPC routes dependent on this IP pool. To perform a cascading delete set the cascade option'
+ )
+ }
+ }
+ }
+ db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter(
+ (a) => a.id !== address.id
+ )
+ return 204
+ },
internetGatewayIpPoolList({ query }) {
const gateway = lookup.internetGateway(query)
const pools = db.internetGatewayIpPools.filter(
@@ -2651,12 +2818,6 @@ export const handlers = makeHandlers({
instanceSerialConsole: NotImplemented,
instanceSerialConsoleStream: NotImplemented,
instanceSshPublicKeyList: NotImplemented,
- internetGatewayCreate: NotImplemented,
- internetGatewayDelete: NotImplemented,
- internetGatewayIpAddressCreate: NotImplemented,
- internetGatewayIpAddressDelete: NotImplemented,
- internetGatewayIpPoolCreate: NotImplemented,
- internetGatewayIpPoolDelete: NotImplemented,
systemIpPoolServiceRangeAdd: NotImplemented,
systemIpPoolServiceRangeList: NotImplemented,
systemIpPoolServiceRangeRemove: NotImplemented,
diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts
index 6df00b31b..1bfb180fd 100644
--- a/test/e2e/vpcs.e2e.ts
+++ b/test/e2e/vpcs.e2e.ts
@@ -7,7 +7,13 @@
*/
import { expect, test } from '@playwright/test'
-import { clickRowAction, expectRowVisible, getPageAsUser, selectOption } from './utils'
+import {
+ clickRowAction,
+ expectRowVisible,
+ expectToast,
+ getPageAsUser,
+ selectOption,
+} from './utils'
test('can nav to VpcPage from /', async ({ page }) => {
await page.goto('/')
@@ -353,17 +359,160 @@ test('can view internet gateways', async ({ page }) => {
await expect(page).toHaveURL(
'/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1'
)
- // Use getByRole instead of getByLabel to avoid matching truncated descriptions
- const sidemodal = page.getByRole('dialog', { name: 'Internet gateway' })
+ await expect(page.getByRole('heading', { name: 'internet-gateway-1' })).toBeVisible()
+
+ const poolsTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) })
+ await expectRowVisible(poolsTable, {
+ name: 'internet-gateway-pool-1',
+ 'IP pool': 'ip-pool-1',
+ })
+
+ const addressesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'address' }) })
+ await expectRowVisible(addressesTable, {
+ name: 'internet-gateway-address-1',
+ address: '123.4.56.3',
+ })
+
+ // gateway 2 has no addresses attached
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
+ await page.getByRole('link', { name: 'internet-gateway-2' }).click()
+ await expect(page.getByRole('heading', { name: 'internet-gateway-2' })).toBeVisible()
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+})
+
+test('can create and delete an internet gateway', async ({ page }) => {
+ await page.goto('/projects/mock-project/vpcs/mock-vpc/internet-gateways')
- await expect(sidemodal.getByText('123.4.56.3')).toBeVisible()
+ await page.getByRole('link', { name: 'New internet gateway' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Create internet gateway' })
+ await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('new-gateway')
+ await dialog.getByRole('textbox', { name: 'Description' }).fill('a new gateway')
+ await dialog.getByRole('button', { name: 'Create internet gateway' }).click()
+ await expectToast(page, 'Internet gateway new-gateway created')
- // close the sidemodal
- await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click()
- await expect(sidemodal).toBeHidden()
+ const table = page.getByRole('table')
+ await expectRowVisible(table, { name: 'new-gateway', description: 'a new gateway' })
+ await expect(table.locator('tbody >> tr')).toHaveCount(3)
+
+ await clickRowAction(page, 'new-gateway', 'Delete')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'Internet gateway new-gateway deleted')
+ await expect(table.locator('tbody >> tr')).toHaveCount(2)
+
+ // this gateway has attachments, so the non-cascading option is disabled
+ // and the notice explains why, with cascade preselected
+ await clickRowAction(page, 'internet-gateway-1', 'Delete')
+ await expect(page.getByRole('radio', { name: 'Delete gateway only' })).toBeDisabled()
+ await expect(
+ page.getByText('1 IP pool and 1 IP address must be detached first')
+ ).toBeVisible()
+ await expect(
+ page.getByRole('radio', { name: 'Delete gateway and detach resources' })
+ ).toBeChecked()
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'Internet gateway internet-gateway-1 deleted')
+ await expect(table.locator('tbody >> tr')).toHaveCount(1)
+})
+
+test('can attach and detach IP pools and addresses on an internet gateway', async ({
+ page,
+}) => {
+ await page.goto(
+ '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-2'
+ )
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+
+ // attach an IP address
+ await page.getByRole('link', { name: 'Attach IP address' }).first().click()
+ const addressDialog = page.getByRole('dialog', { name: 'Attach IP address' })
+ await addressDialog.getByRole('textbox', { name: 'Name' }).fill('my-address')
+ await addressDialog.getByRole('textbox', { name: 'Description' }).fill('an address')
+ await addressDialog.getByRole('textbox', { name: 'IP address' }).fill('123.4.56.7')
+ await addressDialog.getByRole('button', { name: 'Attach IP address' }).click()
+ await expectToast(page, 'IP address my-address attached')
+
+ const addressesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'address' }) })
+ await expectRowVisible(addressesTable, { name: 'my-address', address: '123.4.56.7' })
+
+ // gateways can have at most one IP address attached, so with one attached
+ // the form shows a message and disables submit
+ await page.getByRole('link', { name: 'Attach IP address' }).click()
+ await expect(
+ addressDialog.getByText('Internet gateways can have at most one IP address attached')
+ ).toBeVisible()
+ await expect(
+ addressDialog.getByRole('button', { name: 'Attach IP address' })
+ ).toBeDisabled()
+ await addressDialog.getByRole('button', { name: 'Cancel' }).click()
+ await expect(addressDialog).toBeHidden()
+
+ // detach it again
+ await clickRowAction(page, 'my-address', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'IP address my-address detached')
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+
+ // attach another IP pool
+ await page.getByRole('link', { name: 'Attach IP pool' }).first().click()
+ const poolDialog = page.getByRole('dialog', { name: 'Attach IP pool' })
+ await poolDialog.getByRole('textbox', { name: 'Name' }).fill('my-pool')
+ await poolDialog.getByRole('textbox', { name: 'Description' }).fill('a pool')
+ await poolDialog.getByLabel('IP pool').click()
+ await page.getByRole('option', { name: 'ip-pool-1' }).click()
+ await poolDialog.getByRole('button', { name: 'Attach IP pool' }).click()
+ await expectToast(page, 'IP pool my-pool attached')
+
+ const poolsTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) })
+ await expectRowVisible(poolsTable, { name: 'my-pool', 'IP pool': 'ip-pool-1' })
+
+ // the list view shows the first pool plus a +1 overflow for the second
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
+ await expectRowVisible(page.getByRole('table'), {
+ name: 'internet-gateway-2',
+ 'Attached IP Pool': expect.stringContaining('+1'),
+ })
+ // back to the detail page to detach the pre-existing pool
await page.getByRole('link', { name: 'internet-gateway-2' }).click()
- await expect(sidemodal.getByText('This internet gateway does not have any')).toBeVisible()
+
+ // detach the pool that was already attached in the mock data
+ await clickRowAction(page, 'internet-gateway-pool-2', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'IP pool internet-gateway-pool-2 detached')
+ await expect(poolsTable.locator('tbody >> tr')).toHaveCount(1)
+})
+
+test('cannot detach IP pool or address in use by routes and instances', async ({
+ page,
+}) => {
+ // internet-gateway-1 is targeted by route dc2, and db1's ephemeral IP
+ // (123.4.56.0) is in ip-pool-1's range, so detaching without cascade fails
+ await page.goto(
+ '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1'
+ )
+
+ await clickRowAction(page, 'internet-gateway-pool-1', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, /Could not detach IP pool/)
+
+ const poolsTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) })
+ await expectRowVisible(poolsTable, { name: 'internet-gateway-pool-1' })
+
+ // the attached address (123.4.56.3) is not held by any instance, so
+ // detaching it works even though routes target the gateway
+ await clickRowAction(page, 'internet-gateway-address-1', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'IP address internet-gateway-address-1 detached')
})
test('internet gateway shows proper list of routes targeting it', async ({ page }) => {
@@ -371,16 +520,15 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
await page.goto(
'/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1'
)
- // verify that it has a table with the row showing "mock-custom-router" and "dc2"
- const sidemodal = page.getByRole('dialog', { name: 'Internet Gateway' })
- const table = sidemodal.getByRole('table')
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' })
- await expect(table.locator('tbody >> tr')).toHaveCount(1)
-
- // close the sidemodal
- await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click()
- await expect(sidemodal).toBeHidden()
- // check for the route count; which should be 1
+ // verify that the routes table has a row showing "mock-custom-router" and "dc2"
+ const routesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'Router' }) })
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' })
+ await expect(routesTable.locator('tbody >> tr')).toHaveCount(1)
+
+ // go back to the gateways list and check the route count, which should be 1
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
await expect(page.getByRole('link', { name: '1', exact: true })).toBeVisible()
// go to the Routers tab
await page.getByRole('tab', { name: 'Routers' }).click()
@@ -391,7 +539,6 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
'/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router'
)
- await page.getByRole('link', { name: 'mock-custom-router' }).click()
// create a new route
await page.getByRole('link', { name: 'New route' }).click()
await page.getByRole('textbox', { name: 'Name' }).fill('new-route')
@@ -404,16 +551,16 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
await page.getByRole('link', { name: 'mock-vpc' }).click()
// click on the internet gateways tab and then the internet-gateway-1 link to go to the detail page
await page.getByRole('tab', { name: 'Internet Gateways' }).click()
- // verify that the route count is now 2: click on the link to go to the edit gateway sidemodal
+ // verify that the route count is now 2: click on the link to go to the gateway detail page
await page.getByRole('link', { name: '2', exact: true }).click()
// the new route should be visible in the table
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' })
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'new-route' })
- await expect(table.locator('tbody >> tr')).toHaveCount(2)
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' })
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'new-route' })
+ await expect(routesTable.locator('tbody >> tr')).toHaveCount(2)
- // click on the new-route link to go to the detail page
- await sidemodal.getByRole('link', { name: 'mock-custom-router' }).first().click()
+ // click on the router link to go to the router detail page
+ await routesTable.getByRole('link', { name: 'mock-custom-router' }).first().click()
// expect to be on the view page
await expect(page).toHaveURL(
'/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router'