Skip to content

Commit 68e2dc8

Browse files
Add router tab and routes pages (#2359)
* first stub of Routers and RouterRoutes pages * continuing stub of router routes pages * Add more info to Router Route page * adjustments; TypeValueCell * deleting a route works * Updating strings in target / destination type-value rendering * Update based on feedback in oxide-product-eng * remove underscores in badge * add limit to route list prefetch to match QueryTable fetch * Progress on side modals to create router and route * Router create works * Route create working * Router and route creation / editing working, mostly * Move Router edit side modal to Routers overview page * Add TopBar pickers for VPC and Router * vpcRouterDelete implemented * Updating a router route now works * Update tests * Update to routes, path-builder spec * alphabetize routes * add in a missing route to the spec * Update test to match new error text in console * Update app/forms/vpc-router-create.tsx Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> * Slight refactor on onDismiss * Clean up some duplicated code * Removed old stubbed code * Simpler table construction; add link to VPC in TopBar * refactoring * Optimize route fields * Adjust mock db values * Refactor * Paginate routes * Adding some error handling for issues unearthed when using this branch with dogfood * VPCs can not be used as destinations or targets in cusom routes * additional validations in forms; better comments * small test improvement * Additional restrictions on routes / routers * Update tests * switch to expectRowVisible * use clickRowAction * Refactoring post-review * More refactoring * Rename to RouterPage * git add womp womp * badge and IP Net updates * Add disabled link for 'New route' on system router page * Add DescriptionCell file and update imports * update msw handler for name/description for VPC Route * Update how form handles drop target * Move from useEffect to onChange for TargetType * refactor; pull onChange inline * Update handlers to error if route or router name already exists * Use VPC ID for testing Router uniqueness on update --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com> Co-authored-by: David Crespo <david-crespo@users.noreply.github.com>
1 parent 11e29ed commit 68e2dc8

27 files changed

+1269
-36
lines changed

app/api/hooks.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,14 @@ export const getUsePrefetchedApiQuery =
174174
})
175175
invariant(
176176
result.data,
177-
`Expected query to be prefetched. Key: ${JSON.stringify(queryKey)}`
177+
`Expected query to be prefetched.
178+
Key: ${JSON.stringify(queryKey)}
179+
Ensure the following:
180+
• loader is running
181+
• query matches in both the loader and the component
182+
• request isn't erroring-out server-side (check the Networking tab)
183+
• mock API endpoint is implemented in handlers.ts
184+
`
178185
)
179186
// TS infers non-nullable on a freestanding variable, but doesn't like to do
180187
// it on a property. So we give it a hint

app/api/path-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export type SiloImage = { image?: string }
1515
export type NetworkInterface = Merge<Instance, { interface?: string }>
1616
export type Snapshot = Merge<Project, { snapshot?: string }>
1717
export type Vpc = Merge<Project, { vpc?: string }>
18+
export type VpcRouter = Merge<Vpc, { router?: string }>
19+
export type VpcRouterRoute = Merge<VpcRouter, { route?: string }>
1820
export type VpcSubnet = Merge<Vpc, { subnet?: string }>
1921
export type FirewallRule = Merge<Vpc, { rule?: string }>
2022
export type Silo = { silo?: string }

app/components/TopBarPicker.tsx

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import {
1515
Success12Icon,
1616
} from '@oxide/design-system/icons/react'
1717

18-
import { useInstanceSelector, useIpPoolSelector, useSiloSelector } from '~/hooks'
18+
import {
19+
useInstanceSelector,
20+
useIpPoolSelector,
21+
useSiloSelector,
22+
useVpcRouterSelector,
23+
useVpcSelector,
24+
} from '~/hooks'
1925
import { useCurrentUser } from '~/layouts/AuthenticatedLayout'
2026
import { PAGE_SIZE } from '~/table/QueryTable'
2127
import { Button } from '~/ui/lib/Button'
@@ -229,7 +235,7 @@ export function SiloPicker() {
229235
export function IpPoolPicker() {
230236
// picker only shows up when a pool is in scope
231237
const { pool: poolName } = useIpPoolSelector()
232-
const { data } = useApiQuery('ipPoolList', { query: { limit: 10 } })
238+
const { data } = useApiQuery('ipPoolList', { query: { limit: PAGE_SIZE } })
233239
const items = (data?.items || []).map((pool) => ({
234240
label: pool.name,
235241
to: pb.ipPool({ pool: pool.name }),
@@ -246,6 +252,51 @@ export function IpPoolPicker() {
246252
)
247253
}
248254

255+
/** Used when drilling down into a VPC from the Silo view. */
256+
export function VpcPicker() {
257+
// picker only shows up when a VPC is in scope
258+
const { project, vpc } = useVpcSelector()
259+
const { data } = useApiQuery('vpcList', { query: { project, limit: PAGE_SIZE } })
260+
const items = (data?.items || []).map((v) => ({
261+
label: v.name,
262+
to: pb.vpc({ project, vpc: v.name }),
263+
}))
264+
265+
return (
266+
<TopBarPicker
267+
aria-label="Switch VPC"
268+
category="VPC"
269+
current={vpc}
270+
items={items}
271+
noItemsText="No VPCs found"
272+
to={pb.vpc({ project, vpc })}
273+
/>
274+
)
275+
}
276+
277+
/** Used when drilling down into a VPC Router from the Silo view. */
278+
export function VpcRouterPicker() {
279+
// picker only shows up when a router is in scope
280+
const { project, vpc, router } = useVpcRouterSelector()
281+
const { data } = useApiQuery('vpcRouterList', {
282+
query: { project, vpc, limit: PAGE_SIZE },
283+
})
284+
const items = (data?.items || []).map((r) => ({
285+
label: r.name,
286+
to: pb.vpcRouter({ vpc, project, router: r.name }),
287+
}))
288+
289+
return (
290+
<TopBarPicker
291+
aria-label="Switch router"
292+
category="router"
293+
current={router}
294+
items={items}
295+
noItemsText="No routers found"
296+
/>
297+
)
298+
}
299+
249300
const NoProjectLogo = () => (
250301
<div className="flex h-[34px] w-[34px] items-center justify-center rounded text-secondary bg-secondary">
251302
<Folder16Icon />

app/forms/subnet-edit.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function EditSubnetForm() {
5050
},
5151
})
5252

53-
const defaultValues = R.pick(subnet, ['name', 'description']) satisfies VpcSubnetUpdate
53+
const defaultValues: VpcSubnetUpdate = R.pick(subnet, ['name', 'description'])
5454

5555
const form = useForm({ defaultValues })
5656

app/forms/vpc-router-create.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
import { useNavigate } from 'react-router-dom'
9+
10+
import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/api'
11+
12+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
13+
import { NameField } from '~/components/form/fields/NameField'
14+
import { SideModalForm } from '~/components/form/SideModalForm'
15+
import { useForm, useVpcSelector } from '~/hooks'
16+
import { addToast } from '~/stores/toast'
17+
import { pb } from '~/util/path-builder'
18+
19+
const defaultValues: VpcRouterCreate = {
20+
name: '',
21+
description: '',
22+
}
23+
24+
export function CreateRouterSideModalForm() {
25+
const queryClient = useApiQueryClient()
26+
const vpcSelector = useVpcSelector()
27+
const navigate = useNavigate()
28+
29+
const onDismiss = () => navigate(pb.vpcRouters(vpcSelector))
30+
31+
const createRouter = useApiMutation('vpcRouterCreate', {
32+
onSuccess() {
33+
queryClient.invalidateQueries('vpcRouterList')
34+
addToast({ content: 'Your router has been created' })
35+
onDismiss()
36+
},
37+
})
38+
39+
const form = useForm({ defaultValues })
40+
41+
return (
42+
<SideModalForm
43+
form={form}
44+
formType="create"
45+
resourceName="router"
46+
onDismiss={onDismiss}
47+
onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })}
48+
loading={createRouter.isPending}
49+
submitError={createRouter.error}
50+
>
51+
<NameField name="name" control={form.control} />
52+
<DescriptionField name="description" control={form.control} />
53+
</SideModalForm>
54+
)
55+
}

app/forms/vpc-router-edit.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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+
import {
9+
useNavigate,
10+
type LoaderFunctionArgs,
11+
type NavigateFunction,
12+
} from 'react-router-dom'
13+
14+
import {
15+
apiQueryClient,
16+
useApiMutation,
17+
useApiQueryClient,
18+
usePrefetchedApiQuery,
19+
type VpcRouterUpdate,
20+
} from '@oxide/api'
21+
22+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
23+
import { NameField } from '~/components/form/fields/NameField'
24+
import { SideModalForm } from '~/components/form/SideModalForm'
25+
import { getVpcRouterSelector, useForm, useVpcRouterSelector } from '~/hooks'
26+
import { addToast } from '~/stores/toast'
27+
import { pb } from '~/util/path-builder'
28+
29+
EditRouterSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
30+
const { router, project, vpc } = getVpcRouterSelector(params)
31+
await apiQueryClient.prefetchQuery('vpcRouterView', {
32+
path: { router },
33+
query: { project, vpc },
34+
})
35+
return null
36+
}
37+
38+
export function EditRouterSideModalForm() {
39+
const queryClient = useApiQueryClient()
40+
const routerSelector = useVpcRouterSelector()
41+
const { project, vpc, router } = routerSelector
42+
const { data: routerData } = usePrefetchedApiQuery('vpcRouterView', {
43+
path: { router },
44+
query: { project, vpc },
45+
})
46+
const navigate = useNavigate()
47+
48+
const onDismiss = (navigate: NavigateFunction) => {
49+
navigate(pb.vpcRouters({ project, vpc }))
50+
}
51+
52+
const editRouter = useApiMutation('vpcRouterUpdate', {
53+
onSuccess() {
54+
queryClient.invalidateQueries('vpcRouterList')
55+
addToast({ content: 'Your router has been updated' })
56+
navigate(pb.vpcRouters({ project, vpc }))
57+
},
58+
})
59+
60+
const defaultValues: VpcRouterUpdate = {
61+
name: router,
62+
description: routerData.description,
63+
}
64+
65+
const form = useForm({ defaultValues })
66+
67+
return (
68+
<SideModalForm
69+
form={form}
70+
formType="edit"
71+
resourceName="router"
72+
onDismiss={() => onDismiss(navigate)}
73+
onSubmit={(body) =>
74+
editRouter.mutate({
75+
path: { router },
76+
query: { project, vpc },
77+
body,
78+
})
79+
}
80+
loading={editRouter.isPending}
81+
submitError={editRouter.error}
82+
>
83+
<NameField name="name" control={form.control} />
84+
<DescriptionField name="description" control={form.control} />
85+
</SideModalForm>
86+
)
87+
}

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
import { useNavigate } from 'react-router-dom'
9+
10+
import { useApiMutation, useApiQueryClient, type RouterRouteCreate } from '@oxide/api'
11+
12+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
13+
import { ListboxField } from '~/components/form/fields/ListboxField'
14+
import { NameField } from '~/components/form/fields/NameField'
15+
import { TextField } from '~/components/form/fields/TextField'
16+
import { SideModalForm } from '~/components/form/SideModalForm'
17+
import { fields, targetValueDescription } from '~/forms/vpc-router-route/shared'
18+
import { useForm, useVpcRouterSelector } from '~/hooks'
19+
import { addToast } from '~/stores/toast'
20+
import { pb } from '~/util/path-builder'
21+
22+
const defaultValues: RouterRouteCreate = {
23+
name: '',
24+
description: '',
25+
destination: { type: 'ip', value: '' },
26+
target: { type: 'ip', value: '' },
27+
}
28+
29+
export function CreateRouterRouteSideModalForm() {
30+
const queryClient = useApiQueryClient()
31+
const routerSelector = useVpcRouterSelector()
32+
const navigate = useNavigate()
33+
34+
const onDismiss = () => {
35+
navigate(pb.vpcRouter(routerSelector))
36+
}
37+
38+
const createRouterRoute = useApiMutation('vpcRouterRouteCreate', {
39+
onSuccess() {
40+
queryClient.invalidateQueries('vpcRouterRouteList')
41+
addToast({ content: 'Your route has been created' })
42+
onDismiss()
43+
},
44+
})
45+
46+
const form = useForm({ defaultValues })
47+
const targetType = form.watch('target.type')
48+
49+
return (
50+
<SideModalForm
51+
form={form}
52+
formType="create"
53+
resourceName="route"
54+
onDismiss={onDismiss}
55+
onSubmit={({ name, description, destination, target }) =>
56+
createRouterRoute.mutate({
57+
query: routerSelector,
58+
body: {
59+
name,
60+
description,
61+
destination,
62+
// drop has no value
63+
target: target.type === 'drop' ? { type: target.type } : target,
64+
},
65+
})
66+
}
67+
loading={createRouterRoute.isPending}
68+
submitError={createRouterRoute.error}
69+
>
70+
<NameField name="name" control={form.control} />
71+
<DescriptionField name="description" control={form.control} />
72+
<ListboxField {...fields.destType} control={form.control} />
73+
<TextField {...fields.destValue} control={form.control} />
74+
<ListboxField
75+
{...fields.targetType}
76+
control={form.control}
77+
onChange={(value) => {
78+
// 'outbound' is only valid option when targetType is 'internet_gateway'
79+
if (value === 'internet_gateway') {
80+
form.setValue('target.value', 'outbound')
81+
}
82+
if (value === 'drop') {
83+
form.setValue('target.value', '')
84+
}
85+
}}
86+
/>
87+
{targetType !== 'drop' && (
88+
<TextField
89+
{...fields.targetValue}
90+
control={form.control}
91+
// when targetType is 'internet_gateway', we set it to `outbound` and make it non-editable
92+
disabled={targetType === 'internet_gateway'}
93+
description={targetValueDescription(targetType)}
94+
/>
95+
)}
96+
</SideModalForm>
97+
)
98+
}

0 commit comments

Comments
 (0)