()
+
+export default function InternetGatewayPage() {
+ const { project, vpc, gateway } = useInternetGatewaySelector()
+ const navigate = useNavigate()
+ const { data: gatewayData } = usePrefetchedQuery(gatewayView({ project, vpc, gateway }))
+ const { data: gatewayIpPools } = usePrefetchedQuery(
+ gatewayIpPoolList({ project, vpc, gateway }).optionsFn()
+ )
+ const { data: gatewayIpAddresses } = usePrefetchedQuery(
+ gatewayIpAddressList({ project, vpc, gateway }).optionsFn()
+ )
+ const matchingRoutes = useGatewayRoutes({ project, vpc, gateway })
+
+ const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, {
+ onSuccess() {
+ navigate(pb.vpcInternetGateways({ project, vpc }))
+ queryClient.invalidateEndpoint('internetGatewayList')
+ // prettier-ignore
+ addToast(<>Internet gateway {gateway} deleted>)
+ },
+ })
+
+ const { mutateAsync: detachPool } = useApiMutation(api.internetGatewayIpPoolDelete, {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayIpPoolList')
+ // prettier-ignore
+ addToast(<>IP pool {variables.path.pool} detached>)
+ },
+ })
+
+ const { mutateAsync: detachAddress } = useApiMutation(
+ api.internetGatewayIpAddressDelete,
+ {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayIpAddressList')
+ // prettier-ignore
+ addToast(<>IP address {variables.path.address} detached>)
+ },
+ }
+ )
+
+ const makePoolActions = useCallback(
+ (pool: InternetGatewayIpPool): MenuAction[] => [
+ {
+ label: 'Detach',
+ className: 'destructive',
+ onActivate: () =>
+ confirmAction({
+ doAction: () =>
+ detachPool({
+ path: { pool: pool.name },
+ query: { project, vpc, gateway },
+ }),
+ errorTitle: 'Could not detach IP pool',
+ modalTitle: 'Detach IP pool',
+ modalContent: (
+
+ Are you sure you want to detach IP pool {pool.name} from gateway{' '}
+ {gateway} ?
+
+ ),
+ actionType: 'danger',
+ }),
+ },
+ ],
+ [detachPool, project, vpc, gateway]
+ )
+
+ const makeAddressActions = useCallback(
+ (address: InternetGatewayIpAddress): MenuAction[] => [
+ {
+ label: 'Detach',
+ className: 'destructive',
+ onActivate: () =>
+ confirmAction({
+ doAction: () =>
+ detachAddress({
+ path: { address: address.name },
+ query: { project, vpc, gateway },
+ }),
+ errorTitle: 'Could not detach IP address',
+ modalTitle: 'Detach IP address',
+ modalContent: (
+
+ Are you sure you want to detach IP address {address.name} from
+ gateway {gateway} ?
+
+ ),
+ actionType: 'danger',
+ }),
+ },
+ ],
+ [detachAddress, project, vpc, gateway]
+ )
+
+ const poolsTable = useReactTable({
+ columns: useColsWithActions(
+ [
+ poolColHelper.accessor('name', {}),
+ poolColHelper.accessor('description', Columns.description),
+ poolColHelper.accessor('ipPoolId', {
+ header: 'IP pool',
+ cell: (info) => ,
+ }),
+ poolColHelper.accessor('timeCreated', Columns.timeCreated),
+ ],
+ makePoolActions
+ ),
+ data: gatewayIpPools.items,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const addressesTable = useReactTable({
+ columns: useColsWithActions(
+ [
+ addressColHelper.accessor('name', {}),
+ addressColHelper.accessor('description', Columns.description),
+ addressColHelper.accessor('address', {
+ cell: (info) => ,
+ }),
+ addressColHelper.accessor('timeCreated', Columns.timeCreated),
+ ],
+ makeAddressActions
+ ),
+ data: gatewayIpAddresses.items,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ const routesData = useMemo(
+ () => (matchingRoutes || []).map(([router, route]) => ({ router, route: route.name })),
+ [matchingRoutes]
+ )
+ const routesTable = useReactTable({
+ columns: useMemo(
+ () => [
+ routeColHelper.accessor('router', {
+ header: 'Router',
+ cell: (info) => (
+
+ {info.getValue()}
+
+ ),
+ }),
+ routeColHelper.accessor('route', { header: 'Route' }),
+ ],
+ [project, vpc]
+ ),
+ data: routesData,
+ getCoreRowModel: getCoreRowModel(),
+ })
+
+ useQuickActions(
+ () => [
+ {
+ value: 'Attach IP pool',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewayIpPoolsNew({ project, vpc, gateway }),
+ },
+ {
+ value: 'Attach IP address',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewayIpAddressesNew({ project, vpc, gateway }),
+ },
+ ],
+ [project, vpc, gateway]
+ )
+
+ return (
+ <>
+
+ }>{gateway}
+
+ }
+ summary="An internet gateway connects a VPC to the internet, using addresses from an attached IP pool or an attached IP address."
+ links={[docLinks.gateways]}
+ />
+
+
+
+ deleteGateway({
+ path: { gateway },
+ query: { project, vpc, cascade: true },
+ }),
+ label: gateway,
+ resourceKind: 'internet gateway',
+ extraContent:
+ 'Any attached IP pools and IP addresses will be detached, and routes targeting this gateway will be deleted.',
+ })}
+ className="destructive"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Attach IP pool
+
+
+
+ {gatewayIpPools.items.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ Attach IP address
+
+
+
+ {gatewayIpAddresses.items.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+ {routesData.length > 0 ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ >
+ )
+}
diff --git a/app/pages/project/vpcs/VpcGatewaysTab.tsx b/app/pages/project/vpcs/VpcGatewaysTab.tsx
index bbb095afa..cace85864 100644
--- a/app/pages/project/vpcs/VpcGatewaysTab.tsx
+++ b/app/pages/project/vpcs/VpcGatewaysTab.tsx
@@ -8,17 +8,24 @@
import { useQuery } from '@tanstack/react-query'
import { createColumnHelper } from '@tanstack/react-table'
-import { useMemo } from 'react'
+import { useCallback, useMemo } from 'react'
import { Outlet, type LoaderFunctionArgs } from 'react-router'
-import { api, getListQFn, queryClient, type InternetGateway } from '~/api'
+import { api, getListQFn, queryClient, useApiMutation, type InternetGateway } from '~/api'
+import { HL } from '~/components/HL'
+import { ListPlusOverflow } from '~/components/ListPlusCell'
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
+import { useQuickActions } from '~/hooks/use-quick-actions'
+import { confirmDelete } from '~/stores/confirm-delete'
+import { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { IpPoolCell, ipPoolErrorsAllowedQuery } from '~/table/cells/IpPoolCell'
import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell'
+import { useColsWithActions, type MenuAction } from '~/table/columns/action-col'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
import { CopyableIp } from '~/ui/lib/CopyableIp'
+import { CreateLink } from '~/ui/lib/CreateButton'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { TipIcon } from '~/ui/lib/TipIcon'
import { ALL_ISH } from '~/util/consts'
@@ -43,14 +50,43 @@ const projectIpPoolList = getListQFn(api.ipPoolList, {
const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => {
const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn())
- if (!addresses || addresses.items.length < 1) return
- return
+ const [first, ...rest] = addresses?.items || []
+ if (!first) return
+ return (
+
+ {/* only the leading address is copyable; the rest live in the +N tooltip */}
+
+
+ {rest.map((address) => (
+ {address.address}
+ ))}
+
+
+ )
+}
+
+// plain pool name for the +N tooltip, where IpPoolCell's interactive button
+// wouldn't be usable
+const IpPoolName = ({ ipPoolId }: { ipPoolId: string }) => {
+ const { data: result } = useQuery(ipPoolErrorsAllowedQuery(ipPoolId))
+ if (!result || result.type === 'error') return null
+ return {result.data.name}
}
const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => {
- const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
- if (!gateways || gateways.items.length < 1) return
- return
+ const { data: pools } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
+ const [first, ...rest] = pools?.items || []
+ if (!first) return
+ return (
+
+
+
+ {rest.map((pool) => (
+
+ ))}
+
+
+ )
}
const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => {
@@ -111,12 +147,41 @@ export default function VpcInternetGatewaysTab() {
)
- const columns = useMemo(
+ const { mutateAsync: deleteGateway } = useApiMutation(api.internetGatewayDelete, {
+ onSuccess(_data, variables) {
+ queryClient.invalidateEndpoint('internetGatewayList')
+ // prettier-ignore
+ addToast(<>Internet gateway {variables.path.gateway} deleted>)
+ },
+ })
+
+ const makeActions = useCallback(
+ (gateway: InternetGateway): MenuAction[] => [
+ {
+ label: 'Delete',
+ className: 'destructive',
+ onActivate: confirmDelete({
+ doDelete: () =>
+ deleteGateway({
+ path: { gateway: gateway.name },
+ query: { project, vpc, cascade: true },
+ }),
+ label: gateway.name,
+ resourceKind: 'internet gateway',
+ extraContent:
+ 'Any attached IP pools and IP addresses will be detached, and routes targeting this gateway will be deleted.',
+ }),
+ },
+ ],
+ [deleteGateway, project, vpc]
+ )
+
+ const staticColumns = useMemo(
() => [
colHelper.accessor('name', {
cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })),
@@ -151,14 +216,32 @@ export default function VpcInternetGatewaysTab() {
[project, vpc]
)
+ const columns = useColsWithActions(staticColumns, makeActions)
+
const { table } = useQueryTable({
query: gatewayList({ project, vpc }),
columns,
emptyState,
})
+ useQuickActions(
+ () => [
+ {
+ value: 'New internet gateway',
+ navGroup: 'Actions',
+ action: pb.vpcInternetGatewaysNew({ project, vpc }),
+ },
+ ],
+ [project, vpc]
+ )
+
return (
<>
+
+
+ New internet gateway
+
+
{table}
>
diff --git a/app/pages/project/vpcs/gateway-data.ts b/app/pages/project/vpcs/gateway-data.ts
index 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/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)
+ }
+ />
+
+
{
"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..66df550f4 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -1632,6 +1632,98 @@ export const handlers = makeHandlers({
return paginated(query, gateways)
},
internetGatewayView: ({ path, query }) => lookup.internetGateway({ ...path, ...query }),
+ internetGatewayCreate({ body, query }) {
+ const vpc = lookup.vpc(query)
+ errIfExists(db.internetGateways, { vpc_id: vpc.id, name: body.name })
+
+ const newGateway: Json = {
+ id: uuid(),
+ vpc_id: vpc.id,
+ ...body,
+ ...getTimestamps(),
+ }
+ db.internetGateways.push(newGateway)
+ return json(newGateway, { status: 201 })
+ },
+ internetGatewayDelete({ path, query }) {
+ const gateway = lookup.internetGateway({ ...path, ...query })
+
+ const hasPools = db.internetGatewayIpPools.some(
+ (p) => p.internet_gateway_id === gateway.id
+ )
+ const hasAddresses = db.internetGatewayIpAddresses.some(
+ (a) => a.internet_gateway_id === gateway.id
+ )
+ // without cascade, deletion fails if any IP pools or addresses are attached
+ // https://github.com/oxidecomputer/omicron/blob/99249b4/nexus/db-queries/src/db/datastore/vpc.rs#L1645-L1711
+ if (!query.cascade && (hasPools || hasAddresses)) {
+ const attached = [hasPools && 'IP pools', hasAddresses && 'IP addresses']
+ .filter(Boolean)
+ .join(' and ')
+ throw invalidRequest(
+ `${attached} referencing this gateway exist. To perform a cascading delete set the cascade option`
+ )
+ }
+
+ db.internetGateways = db.internetGateways.filter((g) => g.id !== gateway.id)
+ db.internetGatewayIpPools = db.internetGatewayIpPools.filter(
+ (p) => p.internet_gateway_id !== gateway.id
+ )
+ db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter(
+ (a) => a.internet_gateway_id !== gateway.id
+ )
+ db.vpcRouterRoutes = db.vpcRouterRoutes.filter(
+ (r) => !(r.target.type === 'internet_gateway' && r.target.value === gateway.name)
+ )
+ return 204
+ },
+ internetGatewayIpPoolCreate({ body, query }) {
+ const gateway = lookup.internetGateway(query)
+ const ipPool = lookup.ipPool({ pool: body.ip_pool })
+ errIfExists(db.internetGatewayIpPools, {
+ internet_gateway_id: gateway.id,
+ name: body.name,
+ })
+
+ const newGatewayIpPool: Json = {
+ id: uuid(),
+ internet_gateway_id: gateway.id,
+ ip_pool_id: ipPool.id,
+ name: body.name,
+ description: body.description,
+ ...getTimestamps(),
+ }
+ db.internetGatewayIpPools.push(newGatewayIpPool)
+ return json(newGatewayIpPool, { status: 201 })
+ },
+ internetGatewayIpPoolDelete({ path, query }) {
+ const pool = lookup.internetGatewayIpPool({ ...path, ...query })
+ db.internetGatewayIpPools = db.internetGatewayIpPools.filter((p) => p.id !== pool.id)
+ return 204
+ },
+ internetGatewayIpAddressCreate({ body, query }) {
+ const gateway = lookup.internetGateway(query)
+ errIfExists(db.internetGatewayIpAddresses, {
+ internet_gateway_id: gateway.id,
+ name: body.name,
+ })
+
+ const newGatewayIpAddress: Json = {
+ id: uuid(),
+ internet_gateway_id: gateway.id,
+ ...body,
+ ...getTimestamps(),
+ }
+ db.internetGatewayIpAddresses.push(newGatewayIpAddress)
+ return json(newGatewayIpAddress, { status: 201 })
+ },
+ internetGatewayIpAddressDelete({ path, query }) {
+ const address = lookup.internetGatewayIpAddress({ ...path, ...query })
+ db.internetGatewayIpAddresses = db.internetGatewayIpAddresses.filter(
+ (a) => a.id !== address.id
+ )
+ return 204
+ },
internetGatewayIpPoolList({ query }) {
const gateway = lookup.internetGateway(query)
const pools = db.internetGatewayIpPools.filter(
@@ -2651,12 +2743,6 @@ export const handlers = makeHandlers({
instanceSerialConsole: NotImplemented,
instanceSerialConsoleStream: NotImplemented,
instanceSshPublicKeyList: NotImplemented,
- internetGatewayCreate: NotImplemented,
- internetGatewayDelete: NotImplemented,
- internetGatewayIpAddressCreate: NotImplemented,
- internetGatewayIpAddressDelete: NotImplemented,
- internetGatewayIpPoolCreate: NotImplemented,
- internetGatewayIpPoolDelete: NotImplemented,
systemIpPoolServiceRangeAdd: NotImplemented,
systemIpPoolServiceRangeList: NotImplemented,
systemIpPoolServiceRangeRemove: NotImplemented,
diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts
index 6df00b31b..4c2c171e6 100644
--- a/test/e2e/vpcs.e2e.ts
+++ b/test/e2e/vpcs.e2e.ts
@@ -7,7 +7,13 @@
*/
import { expect, test } from '@playwright/test'
-import { clickRowAction, expectRowVisible, getPageAsUser, selectOption } from './utils'
+import {
+ clickRowAction,
+ expectRowVisible,
+ expectToast,
+ getPageAsUser,
+ selectOption,
+} from './utils'
test('can nav to VpcPage from /', async ({ page }) => {
await page.goto('/')
@@ -353,17 +359,109 @@ test('can view internet gateways', async ({ page }) => {
await expect(page).toHaveURL(
'/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1'
)
- // Use getByRole instead of getByLabel to avoid matching truncated descriptions
- const sidemodal = page.getByRole('dialog', { name: 'Internet gateway' })
+ await expect(page.getByRole('heading', { name: 'internet-gateway-1' })).toBeVisible()
+
+ const poolsTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) })
+ await expectRowVisible(poolsTable, {
+ name: 'internet-gateway-pool-1',
+ 'IP pool': 'ip-pool-1',
+ })
- await expect(sidemodal.getByText('123.4.56.3')).toBeVisible()
+ const addressesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'address' }) })
+ await expectRowVisible(addressesTable, {
+ name: 'internet-gateway-address-1',
+ address: '123.4.56.3',
+ })
- // close the sidemodal
- await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click()
- await expect(sidemodal).toBeHidden()
+ // gateway 2 has no addresses attached
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
+ await page.getByRole('link', { name: 'internet-gateway-2' }).click()
+ await expect(page.getByRole('heading', { name: 'internet-gateway-2' })).toBeVisible()
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+})
+test('can create and delete an internet gateway', async ({ page }) => {
+ await page.goto('/projects/mock-project/vpcs/mock-vpc/internet-gateways')
+
+ await page.getByRole('link', { name: 'New internet gateway' }).click()
+ const dialog = page.getByRole('dialog', { name: 'Create internet gateway' })
+ await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('new-gateway')
+ await dialog.getByRole('textbox', { name: 'Description' }).fill('a new gateway')
+ await dialog.getByRole('button', { name: 'Create internet gateway' }).click()
+ await expectToast(page, 'Internet gateway new-gateway created')
+
+ const table = page.getByRole('table')
+ await expectRowVisible(table, { name: 'new-gateway', description: 'a new gateway' })
+ await expect(table.locator('tbody >> tr')).toHaveCount(3)
+
+ await clickRowAction(page, 'new-gateway', 'Delete')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'Internet gateway new-gateway deleted')
+ await expect(table.locator('tbody >> tr')).toHaveCount(2)
+})
+
+test('can attach and detach IP pools and addresses on an internet gateway', async ({
+ page,
+}) => {
+ await page.goto(
+ '/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-2'
+ )
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+
+ // attach an IP address
+ await page.getByRole('link', { name: 'Attach IP address' }).first().click()
+ const addressDialog = page.getByRole('dialog', { name: 'Attach IP address' })
+ await addressDialog.getByRole('textbox', { name: 'Name' }).fill('my-address')
+ await addressDialog.getByRole('textbox', { name: 'Description' }).fill('an address')
+ await addressDialog.getByRole('textbox', { name: 'IP address' }).fill('123.4.56.7')
+ await addressDialog.getByRole('button', { name: 'Attach IP address' }).click()
+ await expectToast(page, 'IP address my-address attached')
+
+ const addressesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'address' }) })
+ await expectRowVisible(addressesTable, { name: 'my-address', address: '123.4.56.7' })
+
+ // detach it again
+ await clickRowAction(page, 'my-address', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'IP address my-address detached')
+ await expect(page.getByText('No IP addresses attached')).toBeVisible()
+
+ // attach another IP pool
+ await page.getByRole('link', { name: 'Attach IP pool' }).first().click()
+ const poolDialog = page.getByRole('dialog', { name: 'Attach IP pool' })
+ await poolDialog.getByRole('textbox', { name: 'Name' }).fill('my-pool')
+ await poolDialog.getByRole('textbox', { name: 'Description' }).fill('a pool')
+ await poolDialog.getByLabel('IP pool').click()
+ await page.getByRole('option', { name: 'ip-pool-1' }).click()
+ await poolDialog.getByRole('button', { name: 'Attach IP pool' }).click()
+ await expectToast(page, 'IP pool my-pool attached')
+
+ const poolsTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'IP pool' }) })
+ await expectRowVisible(poolsTable, { name: 'my-pool', 'IP pool': 'ip-pool-1' })
+
+ // the list view shows the first pool plus a +1 overflow for the second
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
+ await expectRowVisible(page.getByRole('table'), {
+ name: 'internet-gateway-2',
+ 'Attached IP Pool': expect.stringContaining('+1'),
+ })
+
+ // back to the detail page to detach the pre-existing pool
await page.getByRole('link', { name: 'internet-gateway-2' }).click()
- await expect(sidemodal.getByText('This internet gateway does not have any')).toBeVisible()
+
+ // detach the pool that was already attached in the mock data
+ await clickRowAction(page, 'internet-gateway-pool-2', 'Detach')
+ await page.getByRole('button', { name: 'Confirm' }).click()
+ await expectToast(page, 'IP pool internet-gateway-pool-2 detached')
+ await expect(poolsTable.locator('tbody >> tr')).toHaveCount(1)
})
test('internet gateway shows proper list of routes targeting it', async ({ page }) => {
@@ -371,16 +469,15 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
await page.goto(
'/projects/mock-project/vpcs/mock-vpc/internet-gateways/internet-gateway-1'
)
- // verify that it has a table with the row showing "mock-custom-router" and "dc2"
- const sidemodal = page.getByRole('dialog', { name: 'Internet Gateway' })
- const table = sidemodal.getByRole('table')
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' })
- await expect(table.locator('tbody >> tr')).toHaveCount(1)
-
- // close the sidemodal
- await page.getByRole('contentinfo').getByRole('button', { name: 'Close' }).click()
- await expect(sidemodal).toBeHidden()
- // check for the route count; which should be 1
+ // verify that the routes table has a row showing "mock-custom-router" and "dc2"
+ const routesTable = page
+ .getByRole('table')
+ .filter({ has: page.getByRole('columnheader', { name: 'Router' }) })
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' })
+ await expect(routesTable.locator('tbody >> tr')).toHaveCount(1)
+
+ // go back to the gateways list and check the route count, which should be 1
+ await page.getByRole('link', { name: 'Internet Gateways' }).click()
await expect(page.getByRole('link', { name: '1', exact: true })).toBeVisible()
// go to the Routers tab
await page.getByRole('tab', { name: 'Routers' }).click()
@@ -391,7 +488,6 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
'/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router'
)
- await page.getByRole('link', { name: 'mock-custom-router' }).click()
// create a new route
await page.getByRole('link', { name: 'New route' }).click()
await page.getByRole('textbox', { name: 'Name' }).fill('new-route')
@@ -404,16 +500,16 @@ test('internet gateway shows proper list of routes targeting it', async ({ page
await page.getByRole('link', { name: 'mock-vpc' }).click()
// click on the internet gateways tab and then the internet-gateway-1 link to go to the detail page
await page.getByRole('tab', { name: 'Internet Gateways' }).click()
- // verify that the route count is now 2: click on the link to go to the edit gateway sidemodal
+ // verify that the route count is now 2: click on the link to go to the gateway detail page
await page.getByRole('link', { name: '2', exact: true }).click()
// the new route should be visible in the table
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'dc2' })
- await expectRowVisible(table, { Router: 'mock-custom-router', Route: 'new-route' })
- await expect(table.locator('tbody >> tr')).toHaveCount(2)
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'dc2' })
+ await expectRowVisible(routesTable, { Router: 'mock-custom-router', Route: 'new-route' })
+ await expect(routesTable.locator('tbody >> tr')).toHaveCount(2)
- // click on the new-route link to go to the detail page
- await sidemodal.getByRole('link', { name: 'mock-custom-router' }).first().click()
+ // click on the router link to go to the router detail page
+ await routesTable.getByRole('link', { name: 'mock-custom-router' }).first().click()
// expect to be on the view page
await expect(page).toHaveURL(
'/projects/mock-project/vpcs/mock-vpc/routers/mock-custom-router'