Skip to content

Commit ada302c

Browse files
charlieparkdavid-crespogithub-actions[bot]benjaminleonard
authored
Add read-only UI for internet gateways (#2488)
* Update OMICRON_VERSION * update msw handlers * Other changes from initial branch * IP Pools tab in place * IP Pool ID is copyable from IP Pools tab * Add content to IP Addresses tab and root page * Update OMICRON_VERSION to sha with internet gateways * small tweaks * remove commented-out code * Use proper truncate component * adjust path-builder spec * Add breadcrumb nav for internet gateways * Update default internet gateway IP pool to reflect actual default values * update unrelated test * Remove n+1 query on IpPools * Add IpPoolCell * Remove code that we'll add separately * Upgrade OMICRON_VERSION * No need to extract UtilizationCell now * Get IP Pool tab for Internet Gateways working * Update snapshot, but there might still be some other issues to work out * Some headway, but screen is still blank * Update snapshots for test * Update routes; fix mock data * update tests with mock data, but this should probably get pulled to a new branch * Simplify mock data; renaming the default IP Pool to default was unnecessary and confusing * convert to new useQueryTable * Add internet gateway combobox to router route target field * Sidebar for Internet Gateway coming together * Bot commit: format with prettier * DOM shuffling * Update mock data * Update routes to handle new sidebar and main tab together * Reorder internet gateway sidebar * Update paths and snapshots * use more common internet-gateway-edit syntax for filename * Internet gateways modal tweak (#2607) Internet gateway modal tweaks * Add IP Address and IP Pool columns to Gateway table * test update * small tweaks to sidebar * reverting back to vertical table for now, to render IP Pool alonside join table info * Update copy when missing pool or ip address * Add a test for internet gateway list and sidemodal * Add routes targeting gateway to table * Better handle multiple route spacing; fix test * Update side modal with routes targeting gateway * Update test for showing route * Tweaks to sidemodal * move example gateway route to custom router, not default router * Update tests to reflect gateway route existing on custom router * use more specific params in queries * use titleCrumb for Edit Internet Gateway * fix RR leaf route without element warning * let's use a valid UUID * clean up InternetGatewayRoutes and call it as a component * update the snapshot! * fix gnarly dependent promises in gateways loader * update read only info box * clean up gateway routes fetch logic by extracting shared hook * extract gateway data logic into a separate file * Use count of routes; link to sidemodal * Use count of 0 instead of EmptyCell for route count * use EmptyCell for zero routes, copy tweaks, sentence case * sentence case idp form heading * minor: remove stub e2e test --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Benjamin Leonard <benji@oxide.computer>
1 parent bc3161a commit ada302c

23 files changed

+832
-43
lines changed

app/api/selectors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export type NetworkInterface = Readonly<Merge<Instance, { interface?: string }>>
1616
export type Snapshot = Readonly<Merge<Project, { snapshot?: string }>>
1717
export type Vpc = Readonly<Merge<Project, { vpc?: string }>>
1818
export type VpcRouter = Readonly<Merge<Vpc, { router?: string }>>
19+
export type InternetGateway = Readonly<Merge<Vpc, { gateway?: string }>>
20+
export type InternetGatewayIpAddress = Readonly<
21+
Merge<InternetGateway, { address?: string }>
22+
>
23+
export type InternetGatewayIpPool = Merge<InternetGateway, { pool?: string }>
1924
export type VpcRouterRoute = Readonly<Merge<VpcRouter, { route?: string }>>
2025
export type VpcSubnet = Readonly<Merge<Vpc, { subnet?: string }>>
2126
export type FirewallRule = Readonly<Merge<Vpc, { rule?: string }>>

app/forms/firewall-rules-common.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,9 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
323323
target="_blank"
324324
rel="noreferrer"
325325
>
326-
guest networking guide
326+
Networking
327327
</a>{' '}
328-
and{' '}
328+
guide and the{' '}
329329
<a
330330
href="https://docs.oxide.computer/api/vpc_firewall_rules_update"
331331
// don't need color and hover color because message text is already color-info anyway

app/forms/vpc-router-route-common.tsx

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,14 @@ import { TextField } from '~/components/form/fields/TextField'
2323
import { useVpcRouterSelector } from '~/hooks/use-params'
2424
import { toComboboxItems } from '~/ui/lib/Combobox'
2525
import { Message } from '~/ui/lib/Message'
26+
import { ALL_ISH } from '~/util/consts'
2627
import { validateIp, validateIpNet } from '~/util/ip'
2728

2829
export type RouteFormValues = RouterRouteCreate | Required<RouterRouteUpdate>
2930

3031
export const routeFormMessage = {
3132
vpcSubnetNotModifiable:
3233
'Routes of type VPC Subnet within the system router are not modifiable',
33-
internetGatewayTargetValue:
34-
'For ‘Internet gateway’ targets, the value must be ‘outbound’',
3534
// https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L201-L204
3635
noNewRoutesOnSystemRouter: 'User-provided routes cannot be added to a system router',
3736
// https://github.com/oxidecomputer/omicron/blob/914f5fd7d51f9b060dcc0382a30b607e25df49b2/nexus/src/app/vpc_router.rs#L300-L304
@@ -75,7 +74,7 @@ const destinationValueDescription: Record<RouteDestination['type'], string | und
7574
const targetValuePlaceholder: Record<RouteTarget['type'], string | undefined> = {
7675
ip: 'Enter an IP',
7776
instance: 'Select an instance',
78-
internet_gateway: undefined,
77+
internet_gateway: 'Select an internet gateway',
7978
drop: undefined,
8079
subnet: undefined,
8180
vpc: undefined,
@@ -84,7 +83,7 @@ const targetValuePlaceholder: Record<RouteTarget['type'], string | undefined> =
8483
const targetValueDescription: Record<RouteTarget['type'], string | undefined> = {
8584
ip: 'An IP address, like 10.0.1.5',
8685
instance: undefined,
87-
internet_gateway: routeFormMessage.internetGatewayTargetValue,
86+
internet_gateway: undefined,
8887
drop: undefined,
8988
subnet: undefined,
9089
vpc: undefined,
@@ -103,10 +102,15 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
103102
// usePrefetchedApiQuery items below are initially fetched in the loaders in vpc-router-route-create and -edit
104103
const {
105104
data: { items: vpcSubnets },
106-
} = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: 1000 } })
105+
} = usePrefetchedApiQuery('vpcSubnetList', { query: { project, vpc, limit: ALL_ISH } })
107106
const {
108107
data: { items: instances },
109-
} = usePrefetchedApiQuery('instanceList', { query: { project, limit: 1000 } })
108+
} = usePrefetchedApiQuery('instanceList', { query: { project, limit: ALL_ISH } })
109+
const {
110+
data: { items: internetGateways },
111+
} = usePrefetchedApiQuery('internetGatewayList', {
112+
query: { project, vpc, limit: ALL_ISH },
113+
})
110114

111115
const { control } = form
112116
const destinationType = form.watch('destination.type')
@@ -129,13 +133,35 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
129133
control,
130134
placeholder: targetValuePlaceholder[targetType],
131135
required: true,
132-
// 'internet_gateway' targetTypes can only have the value 'outbound', so we disable the field
133-
disabled: disabled || targetType === 'internet_gateway',
136+
disabled,
134137
description: targetValueDescription[targetType],
135138
// need a default to prevent the text field validation function from
136139
// sticking around when we switch to the combobox
137140
validate: () => undefined,
138141
}
142+
143+
const targetTypeField = () => {
144+
if (targetType === 'drop') {
145+
return null
146+
}
147+
if (targetType === 'instance') {
148+
return <ComboboxField {...targetValueProps} items={toComboboxItems(instances)} />
149+
}
150+
if (targetType === 'internet_gateway') {
151+
return (
152+
<ComboboxField {...targetValueProps} items={toComboboxItems(internetGateways)} />
153+
)
154+
}
155+
return (
156+
<TextField
157+
{...targetValueProps}
158+
validate={(value, { target }) =>
159+
(target.type === 'ip' && validateIp(value)) || undefined
160+
}
161+
/>
162+
)
163+
}
164+
139165
return (
140166
<>
141167
{disabled && (
@@ -176,22 +202,13 @@ export const RouteFormFields = ({ form, disabled }: RouteFormFieldsProps) => {
176202
items={toListboxItems(targetTypes)}
177203
placeholder="Select a target type"
178204
required
179-
onChange={(value) => {
180-
form.setValue('target.value', value === 'internet_gateway' ? 'outbound' : '')
205+
onChange={() => {
206+
form.setValue('target.value', '')
181207
form.clearErrors('target.value')
182208
}}
183209
disabled={disabled}
184210
/>
185-
{targetType === 'drop' ? null : targetType === 'instance' ? (
186-
<ComboboxField {...targetValueProps} items={toComboboxItems(instances)} />
187-
) : (
188-
<TextField
189-
{...targetValueProps}
190-
validate={(value, { target }) =>
191-
(target.type === 'ip' && validateIp(value)) || undefined
192-
}
193-
/>
194-
)}
211+
{targetTypeField()}
195212
</>
196213
)
197214
}

app/forms/vpc-router-route-create.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { HL } from '~/components/HL'
1515
import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common'
1616
import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params'
1717
import { addToast } from '~/stores/toast'
18+
import { ALL_ISH } from '~/util/consts'
1819
import { pb } from '~/util/path-builder'
1920

2021
const defaultValues: RouteFormValues = {
@@ -28,10 +29,13 @@ CreateRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) =
2829
const { project, vpc } = getVpcRouterSelector(params)
2930
await Promise.all([
3031
apiQueryClient.prefetchQuery('vpcSubnetList', {
31-
query: { project, vpc, limit: 1000 },
32+
query: { project, vpc, limit: ALL_ISH },
3233
}),
3334
apiQueryClient.prefetchQuery('instanceList', {
34-
query: { project, limit: 1000 },
35+
query: { project, limit: ALL_ISH },
36+
}),
37+
apiQueryClient.prefetchQuery('internetGatewayList', {
38+
query: { project, vpc, limit: ALL_ISH },
3539
}),
3640
])
3741
return null

app/forms/vpc-router-route-edit.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
} from '~/forms/vpc-router-route-common'
2626
import { getVpcRouterRouteSelector, useVpcRouterRouteSelector } from '~/hooks/use-params'
2727
import { addToast } from '~/stores/toast'
28+
import { ALL_ISH } from '~/util/consts'
2829
import { pb } from '~/util/path-builder'
2930

3031
EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
@@ -35,10 +36,13 @@ EditRouterRouteSideModalForm.loader = async ({ params }: LoaderFunctionArgs) =>
3536
query: { project, vpc, router },
3637
}),
3738
apiQueryClient.prefetchQuery('vpcSubnetList', {
38-
query: { project, vpc, limit: 1000 },
39+
query: { project, vpc, limit: ALL_ISH },
3940
}),
4041
apiQueryClient.prefetchQuery('instanceList', {
41-
query: { project, limit: 1000 },
42+
query: { project, limit: ALL_ISH },
43+
}),
44+
apiQueryClient.prefetchQuery('internetGatewayList', {
45+
query: { project, vpc, limit: ALL_ISH },
4246
}),
4347
])
4448
return null
@@ -65,6 +69,7 @@ export function EditRouterRouteSideModalForm() {
6569
const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', {
6670
onSuccess(updatedRoute) {
6771
queryClient.invalidateQueries('vpcRouterRouteList')
72+
queryClient.invalidateQueries('vpcRouterRouteView')
6873
addToast(<>Route <HL>{updatedRoute.name}</HL> updated</>) // prettier-ignore
6974
navigate(pb.vpcRouter(routerSelector))
7075
},

app/hooks/use-params.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const getFirewallRuleSelector = requireParams('project', 'vpc', 'rule')
4040
export const getVpcRouterSelector = requireParams('project', 'vpc', 'router')
4141
export const getVpcRouterRouteSelector = requireParams('project', 'vpc', 'router', 'route')
4242
export const getVpcSubnetSelector = requireParams('project', 'vpc', 'subnet')
43+
export const getInternetGatewaySelector = requireParams('project', 'vpc', 'gateway')
4344
export const getSiloSelector = requireParams('silo')
4445
export const getSiloImageSelector = requireParams('image')
4546
export const getSshKeySelector = requireParams('sshKey')
@@ -86,6 +87,8 @@ export const useVpcSelector = () => useSelectedParams(getVpcSelector)
8687
export const useVpcRouterSelector = () => useSelectedParams(getVpcRouterSelector)
8788
export const useVpcRouterRouteSelector = () => useSelectedParams(getVpcRouterRouteSelector)
8889
export const useVpcSubnetSelector = () => useSelectedParams(getVpcSubnetSelector)
90+
export const useInternetGatewaySelector = () =>
91+
useSelectedParams(getInternetGatewaySelector)
8992
export const useFirewallRuleSelector = () => useSelectedParams(getFirewallRuleSelector)
9093
export const useSiloSelector = () => useSelectedParams(getSiloSelector)
9194
export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector)

app/pages/project/vpcs/VpcPage/VpcPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ export function VpcPage() {
9898
<Tab to={pb.vpcFirewallRules(vpcSelector)}>Firewall Rules</Tab>
9999
<Tab to={pb.vpcSubnets(vpcSelector)}>Subnets</Tab>
100100
<Tab to={pb.vpcRouters(vpcSelector)}>Routers</Tab>
101+
<Tab to={pb.vpcInternetGateways(vpcSelector)}>Internet Gateways</Tab>
101102
</RouteTabs>
102103
</>
103104
)
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { useQuery } from '@tanstack/react-query'
10+
import { createColumnHelper } from '@tanstack/react-table'
11+
import { useMemo } from 'react'
12+
import { Outlet, type LoaderFunctionArgs } from 'react-router-dom'
13+
14+
import { apiq, getListQFn, queryClient, type InternetGateway } from '~/api'
15+
import { getVpcSelector, useVpcSelector } from '~/hooks/use-params'
16+
import { EmptyCell } from '~/table/cells/EmptyCell'
17+
import { IpPoolCell } from '~/table/cells/IpPoolCell'
18+
import { LinkCell, makeLinkCell } from '~/table/cells/LinkCell'
19+
import { Columns } from '~/table/columns/common'
20+
import { useQueryTable } from '~/table/QueryTable'
21+
import { CopyableIp } from '~/ui/lib/CopyableIp'
22+
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
23+
import { ALL_ISH } from '~/util/consts'
24+
import { pb } from '~/util/path-builder'
25+
import type * as PP from '~/util/path-params'
26+
27+
import {
28+
gatewayIpAddressList,
29+
gatewayIpPoolList,
30+
routeList,
31+
routerList,
32+
useGatewayRoutes,
33+
} from '../../gateway-data'
34+
35+
const gatewayList = ({ project, vpc }: PP.Vpc) =>
36+
getListQFn('internetGatewayList', { query: { project, vpc, limit: ALL_ISH } })
37+
const projectIpPoolList = getListQFn('projectIpPoolList', { query: { limit: ALL_ISH } })
38+
39+
const IpAddressCell = (gatewaySelector: PP.VpcInternetGateway) => {
40+
const { data: addresses } = useQuery(gatewayIpAddressList(gatewaySelector).optionsFn())
41+
if (!addresses || addresses.items.length < 1) return <EmptyCell />
42+
return <CopyableIp ip={addresses.items[0].address} isLinked={false} />
43+
}
44+
45+
const GatewayIpPoolCell = (gatewaySelector: PP.VpcInternetGateway) => {
46+
const { data: gateways } = useQuery(gatewayIpPoolList(gatewaySelector).optionsFn())
47+
if (!gateways || gateways.items.length < 1) return <EmptyCell />
48+
return <IpPoolCell ipPoolId={gateways.items[0].ipPoolId} />
49+
}
50+
51+
const GatewayRoutes = ({ project, vpc, gateway }: PP.VpcInternetGateway) => {
52+
const matchingRoutes = useGatewayRoutes({ project, vpc, gateway })
53+
const to = pb.vpcInternetGateway({ project, vpc, gateway })
54+
if (!matchingRoutes?.length) return <EmptyCell />
55+
return <LinkCell to={to}>{matchingRoutes.length}</LinkCell>
56+
}
57+
58+
const colHelper = createColumnHelper<InternetGateway>()
59+
60+
VpcInternetGatewaysTab.loader = async ({ params }: LoaderFunctionArgs) => {
61+
const { project, vpc } = getVpcSelector(params)
62+
const [gateways, routers] = await Promise.all([
63+
queryClient.fetchQuery(gatewayList({ project, vpc }).optionsFn()),
64+
queryClient.fetchQuery(routerList({ project, vpc }).optionsFn()),
65+
])
66+
67+
await Promise.all([
68+
...gateways.items.flatMap((gateway: InternetGateway) => [
69+
queryClient.prefetchQuery(
70+
gatewayIpAddressList({ project, vpc, gateway: gateway.name }).optionsFn()
71+
),
72+
queryClient.prefetchQuery(
73+
gatewayIpPoolList({ project, vpc, gateway: gateway.name }).optionsFn()
74+
),
75+
]),
76+
...routers.items.map((router) =>
77+
queryClient.prefetchQuery(
78+
routeList({ project, vpc, router: router.name }).optionsFn()
79+
)
80+
),
81+
queryClient.fetchQuery(projectIpPoolList.optionsFn()).then((pools) => {
82+
for (const pool of pools.items) {
83+
const { queryKey } = apiq('projectIpPoolView', { path: { pool: pool.id } })
84+
queryClient.setQueryData(queryKey, pool)
85+
}
86+
}),
87+
] satisfies Promise<unknown>[])
88+
89+
return null
90+
}
91+
92+
export function VpcInternetGatewaysTab() {
93+
const { project, vpc } = useVpcSelector()
94+
95+
const emptyState = (
96+
<EmptyMessage
97+
title="No internet gateways"
98+
body="Create an internet gateway to see it here"
99+
// buttonText="New internet gateway"
100+
// buttonTo={pb.vpcInternetGatewaysNew(vpcSelector)}
101+
/>
102+
)
103+
104+
const columns = useMemo(
105+
() => [
106+
colHelper.accessor('name', {
107+
cell: makeLinkCell((gateway) => pb.vpcInternetGateway({ project, vpc, gateway })),
108+
}),
109+
colHelper.accessor('description', Columns.description),
110+
colHelper.accessor('name', {
111+
// ID needed to avoid key collision with other name column
112+
id: 'ip-address',
113+
header: 'Attached IP Address',
114+
cell: (info) => (
115+
<IpAddressCell project={project} vpc={vpc} gateway={info.getValue()} />
116+
),
117+
}),
118+
colHelper.accessor('name', {
119+
// ID needed to avoid key collision with other name column
120+
id: 'ip-pool',
121+
header: 'Attached IP Pool',
122+
cell: (info) => (
123+
<GatewayIpPoolCell project={project} vpc={vpc} gateway={info.getValue()} />
124+
),
125+
}),
126+
colHelper.accessor('name', {
127+
// ID needed to avoid key collision with other name column
128+
id: 'routes',
129+
header: 'Routes',
130+
cell: (info) => (
131+
<GatewayRoutes project={project} vpc={vpc} gateway={info.getValue()} />
132+
),
133+
}),
134+
colHelper.accessor('timeCreated', Columns.timeCreated),
135+
],
136+
[project, vpc]
137+
)
138+
139+
const { table } = useQueryTable({
140+
query: gatewayList({ project, vpc }),
141+
columns,
142+
emptyState,
143+
})
144+
145+
return (
146+
<>
147+
{table}
148+
<Outlet />
149+
</>
150+
)
151+
}

0 commit comments

Comments
 (0)