Skip to content

Commit

Permalink
Add read-only UI for internet gateways (#2488)
Browse files Browse the repository at this point in the history
* Update OMICRON_VERSION

* update msw handlers

* Other changes from initial branch

* IP Pools tab in place

* IP Pool ID is copyable from IP Pools tab

* Add content to IP Addresses tab and root page

* Update OMICRON_VERSION to sha with internet gateways

* small tweaks

* remove commented-out code

* Use proper truncate component

* adjust path-builder spec

* Add breadcrumb nav for internet gateways

* Update default internet gateway IP pool to reflect actual default values

* update unrelated test

* Remove n+1 query on IpPools

* Add IpPoolCell

* Remove code that we'll add separately

* Upgrade OMICRON_VERSION

* No need to extract UtilizationCell now

* Get IP Pool tab for Internet Gateways working

* Update snapshot, but there might still be some other issues to work out

* Some headway, but screen is still blank

* Update snapshots for test

* Update routes; fix mock data

* update tests with mock data, but this should probably get pulled to a new branch

* Simplify mock data; renaming the default IP Pool to default was unnecessary and confusing

* convert to new useQueryTable

* Add internet gateway combobox to router route target field

* Sidebar for Internet Gateway coming together

* Bot commit: format with prettier

* DOM shuffling

* Update mock data

* Update routes to handle new sidebar and main tab together

* Reorder internet gateway sidebar

* Update paths and snapshots

* use more common internet-gateway-edit syntax for filename

* Internet gateways modal tweak (#2607)

Internet gateway modal tweaks

* Add IP Address and IP Pool columns to Gateway table

* test update

* small tweaks to sidebar

* reverting back to vertical table for now, to render IP Pool alonside join table info

* Update copy when missing pool or ip address

* Add a test for internet gateway list and sidemodal

* Add routes targeting gateway to table

* Better handle multiple route spacing; fix test

* Update side modal with routes targeting gateway

* Update test for showing route

* Tweaks to sidemodal

* move example gateway route to custom router, not default router

* Update tests to reflect gateway route existing on custom router

* use more specific params in queries

* use titleCrumb for Edit Internet Gateway

* fix RR leaf route without element warning

* let's use a valid UUID

* clean up InternetGatewayRoutes and call it as a component

* update the snapshot!

* fix gnarly dependent promises in gateways loader

* update read only info box

* clean up gateway routes fetch logic by extracting shared hook

* extract gateway data logic into a separate file

* Use count of routes; link to sidemodal

* Use count of 0 instead of EmptyCell for route count

* use EmptyCell for zero routes, copy tweaks, sentence case

* sentence case idp form heading

* minor: remove stub e2e test

---------

Co-authored-by: David Crespo <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Benjamin Leonard <[email protected]>
  • Loading branch information
4 people authored Dec 13, 2024
1 parent bc3161a commit ada302c
Show file tree
Hide file tree
Showing 23 changed files with 832 additions and 43 deletions.
5 changes: 5 additions & 0 deletions app/api/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export type NetworkInterface = Readonly<Merge<Instance, { interface?: string }>>
export type Snapshot = Readonly<Merge<Project, { snapshot?: string }>>
export type Vpc = Readonly<Merge<Project, { vpc?: string }>>
export type VpcRouter = Readonly<Merge<Vpc, { router?: string }>>
export type InternetGateway = Readonly<Merge<Vpc, { gateway?: string }>>
export type InternetGatewayIpAddress = Readonly<
Merge<InternetGateway, { address?: string }>
>
export type InternetGatewayIpPool = Merge<InternetGateway, { pool?: string }>
export type VpcRouterRoute = Readonly<Merge<VpcRouter, { route?: string }>>
export type VpcSubnet = Readonly<Merge<Vpc, { subnet?: string }>>
export type FirewallRule = Readonly<Merge<Vpc, { rule?: string }>>
Expand Down
4 changes: 2 additions & 2 deletions app/forms/firewall-rules-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,9 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
target="_blank"
rel="noreferrer"
>
guest networking guide
Networking
</a>{' '}
and{' '}
guide and the{' '}
<a
href="https://docs.oxide.computer/api/vpc_firewall_rules_update"
// don't need color and hover color because message text is already color-info anyway
Expand Down
57 changes: 37 additions & 20 deletions app/forms/vpc-router-route-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@ import { TextField } from '~/components/form/fields/TextField'
import { useVpcRouterSelector } from '~/hooks/use-params'
import { toComboboxItems } from '~/ui/lib/Combobox'
import { Message } from '~/ui/lib/Message'
import { ALL_ISH } from '~/util/consts'
import { validateIp, validateIpNet } from '~/util/ip'

export type RouteFormValues = RouterRouteCreate | Required<RouterRouteUpdate>

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
Expand Down Expand Up @@ -75,7 +74,7 @@ const destinationValueDescription: Record<RouteDestination['type'], string | und
const targetValuePlaceholder: Record<RouteTarget['type'], string | undefined> = {
ip: 'Enter an IP',
instance: 'Select an instance',
internet_gateway: undefined,
internet_gateway: 'Select an internet gateway',
drop: undefined,
subnet: undefined,
vpc: undefined,
Expand All @@ -84,7 +83,7 @@ const targetValuePlaceholder: Record<RouteTarget['type'], string | undefined> =
const targetValueDescription: Record<RouteTarget['type'], string | undefined> = {
ip: 'An IP address, like 10.0.1.5',
instance: undefined,
internet_gateway: routeFormMessage.internetGatewayTargetValue,
internet_gateway: undefined,
drop: undefined,
subnet: undefined,
vpc: undefined,
Expand All @@ -103,10 +102,15 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
// usePrefetchedApiQuery items below are initially fetched in the loaders in vpc-router-route-create and -edit
const {
data: { items: vpcSubnets },
} = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: 1000 } })
} = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } })
const {
data: { items: instances },
} = usePrefetchedApiQuery('instanceList', { query: { project, limit: 1000 } })
} = usePrefetchedApiQuery('instanceList', { query: { project, limit: ALL_ISH } })
const {
data: { items: internetGateways },
} = usePrefetchedApiQuery('internetGatewayList', {
query: { project, vpc, limit: ALL_ISH },
})

const { control } = form
const destinationType = form.watch('destination.type')
Expand All @@ -129,13 +133,35 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
control,
placeholder: targetValuePlaceholder[targetType],
required: true,
// 'internet_gateway' targetTypes can only have the value 'outbound', so we disable the field
disabled: disabled || targetType === 'internet_gateway',
disabled,
description: targetValueDescription[targetType],
// need a default to prevent the text field validation function from
// sticking around when we switch to the combobox
validate: () => undefined,
}

const targetTypeField = () => {
if (targetType === 'drop') {
return null
}
if (targetType === 'instance') {
return <ComboboxField {...targetValueProps} items={toComboboxItems(instances)} />
}
if (targetType === 'internet_gateway') {
return (
<ComboboxField {...targetValueProps} items={toComboboxItems(internetGateways)} />
)
}
return (
<TextField
{...targetValueProps}
validate={(value, { target }) =>
(target.type === 'ip' && validateIp(value)) || undefined
}
/>
)
}

return (
<>
{disabled && (
Expand Down Expand Up @@ -176,22 +202,13 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
items={toListboxItems(targetTypes)}
placeholder="Select a target type"
required
onChange={(value) => {
form.setValue('target.value', value === 'internet_gateway' ? 'outbound' : '')
onChange={() => {
form.setValue('target.value', '')
form.clearErrors('target.value')
}}
disabled={disabled}
/>
{targetType === 'drop' ? null : targetType === 'instance' ? (
<ComboboxField {...targetValueProps} items={toComboboxItems(instances)} />
) : (
<TextField
{...targetValueProps}
validate={(value, { target }) =>
(target.type === 'ip' && validateIp(value)) || undefined
}
/>
)}
{targetTypeField()}
</>
)
}
8 changes: 6 additions & 2 deletions app/forms/vpc-router-route-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { HL } from '~/components/HL'
import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common'
import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { ALL_ISH } from '~/util/consts'
import { pb } from '~/util/path-builder'

const defaultValues: RouteFormValues = {
Expand All @@ -28,10 +29,13 @@ CreateRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) =
const { project, vpc } = getVpcRouterSelector(params)
await Promise.all([
apiQueryClient.prefetchQuery('vpcSubnetList', {
query: { project, vpc, limit: 1000 },
query: { project, vpc, limit: ALL_ISH },
}),
apiQueryClient.prefetchQuery('instanceList', {
query: { project, limit: 1000 },
query: { project, limit: ALL_ISH },
}),
apiQueryClient.prefetchQuery('internetGatewayList', {
query: { project, vpc, limit: ALL_ISH },
}),
])
return null
Expand Down
9 changes: 7 additions & 2 deletions app/forms/vpc-router-route-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from '~/forms/vpc-router-route-common'
import { getVpcRouterRouteSelector, useVpcRouterRouteSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { ALL_ISH } from '~/util/consts'
import { pb } from '~/util/path-builder'

EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
Expand All @@ -35,10 +36,13 @@ EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) =>
query: { project, vpc, router },
}),
apiQueryClient.prefetchQuery('vpcSubnetList', {
query: { project, vpc, limit: 1000 },
query: { project, vpc, limit: ALL_ISH },
}),
apiQueryClient.prefetchQuery('instanceList', {
query: { project, limit: 1000 },
query: { project, limit: ALL_ISH },
}),
apiQueryClient.prefetchQuery('internetGatewayList', {
query: { project, vpc, limit: ALL_ISH },
}),
])
return null
Expand All @@ -65,6 +69,7 @@ export function EditRouterRouteSideModalForm() {
const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', {
onSuccess(updatedRoute) {
queryClient.invalidateQueries('vpcRouterRouteList')
queryClient.invalidateQueries('vpcRouterRouteView')
addToast(<>Route <HL>{updatedRoute.name}</HL> updated</>) // prettier-ignore
navigate(pb.vpcRouter(routerSelector))
},
Expand Down
3 changes: 3 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule')
export const getVpcRouterSelector = requireParams('project', 'vpc', 'router')
export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route')
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
export const getInternetGatewaySelector = requireParams('project', 'vpc', 'gateway')
export const getSiloSelector = requireParams('silo')
export const getSiloImageSelector = requireParams('image')
export const getSshKeySelector = requireParams('sshKey')
Expand Down Expand Up @@ -86,6 +87,8 @@ export const useVpcSelector = () => useSelectedParams(getVpcSelector)
export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector)
export const useVpcRouterRouteSelector = () => useSelectedParams(getVpcRouterRouteSelector)
export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector)
export const useInternetGatewaySelector = () =>
useSelectedParams(getInternetGatewaySelector)
export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector)
export const useSiloSelector = () => useSelectedParams(getSiloSelector)
export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector)
Expand Down
1 change: 1 addition & 0 deletions app/pages/project/vpcs/VpcPage/VpcPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export function VpcPage() {
<Tab to={pb.vpcFirewallRules(vpcSelector)}>Firewall Rules</Tab>
<Tab to={pb.vpcSubnets(vpcSelector)}>Subnets</Tab>
<Tab to={pb.vpcRouters(vpcSelector)}>Routers</Tab>
<Tab to={pb.vpcInternetGateways(vpcSelector)}>Internet Gateways</Tab>
</RouteTabs>
</>
)
Expand Down
151 changes: 151 additions & 0 deletions app/pages/project/vpcs/VpcPage/tabs/VpcGatewaysTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/

import { useQuery } from '@tanstack/react-query'
import { createColumnHelper } from '@tanstack/react-table'
import { useMemo } from 'react'
import { Outlet, type LoaderFunctionArgs } from 'react-router-dom'

import { apiq, getListQFn, queryClient, type InternetGateway } from '~/api'
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { IpPoolCell } from '~/table/cells/IpPoolCell'
import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell'
import { Columns } from '~/table/columns/common'
import { useQueryTable } from '~/table/QueryTable'
import { CopyableIp } from '~/ui/lib/CopyableIp'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
import { ALL_ISH } from '~/util/consts'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

import {
gatewayIpAddressList,
gatewayIpPoolList,
routeList,
routerList,
useGatewayRoutes,
} from '../../gateway-data'

const gatewayList = ({ project, vpc }: PP.Vpc) =>
getListQFn('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } })
const projectIpPoolList = getListQFn('projectIpPoolList', { query: { limit: ALL_ISH } })

const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => {
const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn())
if (!addresses || addresses.items.length < 1) return <EmptyCell />
return <CopyableIp ip={addresses.items[0].address} isLinked={false} />
}

const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => {
const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
if (!gateways || gateways.items.length < 1) return <EmptyCell />
return <IpPoolCell ipPoolId={gateways.items[0].ipPoolId} />
}

const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => {
const matchingRoutes = useGatewayRoutes({ project, vpc, gateway })
const to = pb.vpcInternetGateway({ project, vpc, gateway })
if (!matchingRoutes?.length) return <EmptyCell />
return <LinkCell to={to}>{matchingRoutes.length}</LinkCell>
}

const colHelper = createColumnHelper<InternetGateway>()

VpcInternetGatewaysTab.loader = async ({ params }: LoaderFunctionArgs) => {
const { project, vpc } = getVpcSelector(params)
const [gateways, routers] = await Promise.all([
queryClient.fetchQuery(gatewayList({ project, vpc }).optionsFn()),
queryClient.fetchQuery(routerList({ project, vpc }).optionsFn()),
])

await Promise.all([
...gateways.items.flatMap((gateway: InternetGateway) => [
queryClient.prefetchQuery(
gatewayIpAddressList({ project, vpc, gateway: gateway.name }).optionsFn()
),
queryClient.prefetchQuery(
gatewayIpPoolList({ project, vpc, gateway: gateway.name }).optionsFn()
),
]),
...routers.items.map((router) =>
queryClient.prefetchQuery(
routeList({ project, vpc, router: router.name }).optionsFn()
)
),
queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => {
for (const pool of pools.items) {
const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } })
queryClient.setQueryData(queryKey, pool)
}
}),
] satisfies Promise<unknown>[])

return null
}

export function VpcInternetGatewaysTab() {
const { project, vpc } = useVpcSelector()

const emptyState = (
<EmptyMessage
title="No internet gateways"
body="Create an internet gateway to see it here"
// buttonText="New internet gateway"
// buttonTo={pb.vpcInternetGatewaysNew(vpcSelector)}
/>
)

const columns = useMemo(
() => [
colHelper.accessor('name', {
cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })),
}),
colHelper.accessor('description', Columns.description),
colHelper.accessor('name', {
// ID needed to avoid key collision with other name column
id: 'ip-address',
header: 'Attached IP Address',
cell: (info) => (
<IpAddressCell project={project} vpc={vpc} gateway={info.getValue()} />
),
}),
colHelper.accessor('name', {
// ID needed to avoid key collision with other name column
id: 'ip-pool',
header: 'Attached IP Pool',
cell: (info) => (
<GatewayIpPoolCell project={project} vpc={vpc} gateway={info.getValue()} />
),
}),
colHelper.accessor('name', {
// ID needed to avoid key collision with other name column
id: 'routes',
header: 'Routes',
cell: (info) => (
<GatewayRoutes project={project} vpc={vpc} gateway={info.getValue()} />
),
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
],
[project, vpc]
)

const { table } = useQueryTable({
query: gatewayList({ project, vpc }),
columns,
emptyState,
})

return (
<>
{table}
<Outlet />
</>
)
}
Loading

0 comments on commit ada302c

Please sign in to comment.