Skip to content

Commit 1a4f5d8

Browse files
Manage IP pools (#1910)
* basic IP pools page and list silos for pool * Stubbing out tabs for silo IP pools * Stubbing out tabs for silo IP pools * Switch to query param-based URL and tabs * Copy change * Cleanup on IP Pools page, but not there yet * use the right API endpoint * make/clear default row action * use silo page loader for API calls backing the tabs * tweak display * move fleet role mapping into a tab * add properties table to silo detail * unlink pool * make e2e tests pass, improve empty fleet role mapping state * add IP ranges * make /system/ip-pools the top-level route for now * add apology for incomplete UI, will hopefully get far enough to remove * create pool form * delete IP pool * ip pool edit form * update path-builder test * basic e2e tests * silo names on IP pools silo list, link to silo, help text * make *NameFromId rendering logic more uniform * change IP pools page back to Networking with one tab * confirm actions on silo IP pools list * add docs links, remove apology callout * confirm unlink in ip pool silos list * link silo from ip pool silos list * link pool from silo pools list * error toast and loading states on link modals * fix e2e test with confirm * fix already exists check on mock ip pool update handler * add disabled range add/remove buttons to suggest using API instead --------- Co-authored-by: Charlie Park <[email protected]>
1 parent 34596e3 commit 1a4f5d8

38 files changed

+1481
-238
lines changed

app/components/ExternalLink.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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 cn from 'classnames'
9+
10+
type ExternalLinkProps = {
11+
href: string
12+
className?: string
13+
children: React.ReactNode
14+
}
15+
16+
export function ExternalLink({ href, className, children }: ExternalLinkProps) {
17+
return (
18+
<a
19+
href={href}
20+
className={cn('underline text-accent-secondary hover:text-accent', className)}
21+
target="_blank"
22+
rel="noreferrer"
23+
>
24+
{children}
25+
</a>
26+
)
27+
}

app/components/HL.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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 { classed } from '@oxide/util'
9+
10+
export const HL = classed.span`text-sans-semi-md text-default`

app/components/TopBarPicker.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
Wrap,
2121
} from '@oxide/ui'
2222

23-
import { useInstanceSelector, useSiloSelector } from 'app/hooks'
23+
import { useInstanceSelector, useIpPoolSelector, useSiloSelector } from 'app/hooks'
2424
import { useCurrentUser } from 'app/layouts/AuthenticatedLayout'
2525
import { pb } from 'app/util/path-builder'
2626

@@ -228,6 +228,27 @@ export function SiloPicker() {
228228
)
229229
}
230230

231+
/** Used when drilling down into a pool from the System/Networking view. */
232+
export function IpPoolPicker() {
233+
// picker only shows up when a pool is in scope
234+
const { pool: poolName } = useIpPoolSelector()
235+
const { data } = useApiQuery('ipPoolList', { query: { limit: 10 } })
236+
const items = (data?.items || []).map((pool) => ({
237+
label: pool.name,
238+
to: pb.ipPool({ pool: pool.name }),
239+
}))
240+
241+
return (
242+
<TopBarPicker
243+
aria-label="Switch pool"
244+
category="IP Pools"
245+
current={poolName}
246+
items={items}
247+
noItemsText="No IP pools found"
248+
/>
249+
)
250+
}
251+
231252
const NoProjectLogo = () => (
232253
<div className="flex h-[34px] w-[34px] items-center justify-center rounded text-secondary bg-secondary">
233254
<Folder16Icon />

app/forms/ip-pool-create.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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 IpPoolCreate } from '@oxide/api'
11+
12+
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
13+
import { useForm } from 'app/hooks'
14+
import { addToast } from 'app/stores/toast'
15+
import { pb } from 'app/util/path-builder'
16+
17+
const defaultValues: IpPoolCreate = {
18+
name: '',
19+
description: '',
20+
}
21+
22+
export function CreateIpPoolSideModalForm() {
23+
const navigate = useNavigate()
24+
const queryClient = useApiQueryClient()
25+
26+
const onDismiss = () => navigate(pb.ipPools())
27+
28+
const createPool = useApiMutation('ipPoolCreate', {
29+
onSuccess(_pool) {
30+
queryClient.invalidateQueries('ipPoolList')
31+
addToast({ content: 'Your IP pool has been created' })
32+
navigate(pb.ipPools())
33+
},
34+
})
35+
36+
const form = useForm({ defaultValues })
37+
38+
return (
39+
<SideModalForm
40+
id="create-pool-form"
41+
form={form}
42+
title="Create IP pool"
43+
onDismiss={onDismiss}
44+
onSubmit={({ name, description }) => {
45+
createPool.mutate({ body: { name, description } })
46+
}}
47+
loading={createPool.isPending}
48+
submitError={createPool.error}
49+
>
50+
<NameField name="name" control={form.control} />
51+
<DescriptionField name="description" control={form.control} />
52+
</SideModalForm>
53+
)
54+
}

app/forms/ip-pool-edit.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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, type LoaderFunctionArgs } from 'react-router-dom'
9+
10+
import {
11+
apiQueryClient,
12+
useApiMutation,
13+
useApiQueryClient,
14+
usePrefetchedApiQuery,
15+
} from '@oxide/api'
16+
17+
import { DescriptionField, NameField, SideModalForm } from 'app/components/form'
18+
import { getIpPoolSelector, useForm, useIpPoolSelector, useToast } from 'app/hooks'
19+
import { pb } from 'app/util/path-builder'
20+
21+
EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => {
22+
const { pool } = getIpPoolSelector(params)
23+
await apiQueryClient.prefetchQuery('ipPoolView', { path: { pool } })
24+
return null
25+
}
26+
27+
export function EditIpPoolSideModalForm() {
28+
const queryClient = useApiQueryClient()
29+
const addToast = useToast()
30+
const navigate = useNavigate()
31+
32+
const poolSelector = useIpPoolSelector()
33+
34+
const onDismiss = () => navigate(pb.ipPools())
35+
36+
const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector })
37+
38+
const editPool = useApiMutation('ipPoolUpdate', {
39+
onSuccess(_pool) {
40+
queryClient.invalidateQueries('ipPoolList')
41+
addToast({ content: 'Your IP pool has been updated' })
42+
onDismiss()
43+
},
44+
})
45+
46+
const form = useForm({ defaultValues: pool })
47+
48+
return (
49+
<SideModalForm
50+
id="edit-pool-form"
51+
form={form}
52+
title="Edit IP pool"
53+
onDismiss={onDismiss}
54+
onSubmit={({ name, description }) => {
55+
editPool.mutate({ path: poolSelector, body: { name, description } })
56+
}}
57+
loading={editPool.isPending}
58+
submitError={editPool.error}
59+
submitLabel="Save changes"
60+
>
61+
<NameField name="name" control={form.control} />
62+
<DescriptionField name="description" control={form.control} />
63+
</SideModalForm>
64+
)
65+
}

app/forms/project-create.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ export function CreateProjectSideModalForm() {
3636
},
3737
})
3838

39-
// TODO: RHF docs warn about the performance impact of validating on every
40-
// change
4139
const form = useForm({ defaultValues })
4240

4341
return (

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const getProjectImageSelector = requireParams('project', 'image')
4242
export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
4343
export const requireSledParams = requireParams('sledId')
4444
export const requireUpdateParams = requireParams('version')
45+
export const getIpPoolSelector = requireParams('pool')
4546

4647
/**
4748
* Turn `getThingSelector`, a pure function on a params object, into a hook
@@ -79,3 +80,4 @@ export const useSiloImageSelector = () => useSelectedParams(getSiloImageSelector
7980
export const useIdpSelector = () => useSelectedParams(getIdpSelector)
8081
export const useSledParams = () => useSelectedParams(requireSledParams)
8182
export const useUpdateParams = () => useSelectedParams(requireUpdateParams)
83+
export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector)

app/layouts/SystemLayout.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ import { useMemo } from 'react'
99
import { useLocation, useNavigate, useParams } from 'react-router-dom'
1010

1111
import { apiQueryClient } from '@oxide/api'
12-
import { Cloud16Icon, Divider, Metrics16Icon, Storage16Icon } from '@oxide/ui'
12+
import {
13+
Cloud16Icon,
14+
Divider,
15+
Metrics16Icon,
16+
Networking16Icon,
17+
Storage16Icon,
18+
} from '@oxide/ui'
1319

1420
import { trigger404 } from 'app/components/ErrorBoundary'
1521
import { DocsLinkItem, NavLinkItem, Sidebar } from 'app/components/Sidebar'
1622
import { TopBar } from 'app/components/TopBar'
17-
import { SiloPicker, SiloSystemPicker } from 'app/components/TopBarPicker'
23+
import { IpPoolPicker, SiloPicker, SiloSystemPicker } from 'app/components/TopBarPicker'
1824
import { useQuickActions } from 'app/hooks'
1925
import { pb } from 'app/util/path-builder'
2026

@@ -49,7 +55,7 @@ export default function SystemLayout() {
4955
// robust way of doing this would be to make a separate layout for the
5056
// silo-specific routes in the route config, but it's overkill considering
5157
// this is a one-liner. Switch to that approach at the first sign of trouble.
52-
const { silo } = useParams()
58+
const { silo, pool } = useParams()
5359
const navigate = useNavigate()
5460
const { pathname } = useLocation()
5561

@@ -60,6 +66,7 @@ export default function SystemLayout() {
6066
{ value: 'Silos', path: pb.silos() },
6167
{ value: 'Utilization', path: pb.systemUtilization() },
6268
{ value: 'Inventory', path: pb.inventory() },
69+
{ value: 'Networking', path: pb.ipPools() },
6370
]
6471
// filter out the entry for the path we're currently on
6572
.filter((i) => i.path !== pathname)
@@ -84,6 +91,7 @@ export default function SystemLayout() {
8491
<TopBar>
8592
<SiloSystemPicker value="system" />
8693
{silo && <SiloPicker />}
94+
{pool && <IpPoolPicker />}
8795
</TopBar>
8896
<Sidebar>
8997
<Sidebar.Nav>
@@ -103,15 +111,9 @@ export default function SystemLayout() {
103111
<NavLinkItem to={pb.sledInventory()}>
104112
<Storage16Icon /> Inventory
105113
</NavLinkItem>
106-
{/* <NavLinkItem to={pb.systemHealth()} disabled>
107-
<Health16Icon /> Health
108-
</NavLinkItem>
109-
<NavLinkItem to={pb.systemUpdates()} disabled>
110-
<SoftwareUpdate16Icon /> System Update
111-
</NavLinkItem>
112-
<NavLinkItem to={pb.systemNetworking()} disabled>
114+
<NavLinkItem to={pb.ipPools()}>
113115
<Networking16Icon /> Networking
114-
</NavLinkItem> */}
116+
</NavLinkItem>
115117
</Sidebar.Nav>
116118
</Sidebar>
117119
<ContentPane />

app/pages/SiloAccessPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ import {
3232
import { groupBy, isTruthy } from '@oxide/util'
3333

3434
import { AccessNameCell } from 'app/components/AccessNameCell'
35+
import { HL } from 'app/components/HL'
3536
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
3637
import {
3738
SiloAccessAddUserSideModal,
3839
SiloAccessEditUserSideModal,
3940
} from 'app/forms/silo-access'
40-
import { confirmDelete, HL } from 'app/stores/confirm-delete'
41+
import { confirmDelete } from 'app/stores/confirm-delete'
4142

4243
const EmptyState = ({ onClick }: { onClick: () => void }) => (
4344
<TableEmptyBox>

app/pages/project/access/ProjectAccessPage.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,14 @@ import {
3535
import { groupBy, isTruthy } from '@oxide/util'
3636

3737
import { AccessNameCell } from 'app/components/AccessNameCell'
38+
import { HL } from 'app/components/HL'
3839
import { RoleBadgeCell } from 'app/components/RoleBadgeCell'
3940
import {
4041
ProjectAccessAddUserSideModal,
4142
ProjectAccessEditUserSideModal,
4243
} from 'app/forms/project-access'
4344
import { getProjectSelector, useProjectSelector } from 'app/hooks'
44-
import { confirmDelete, HL } from 'app/stores/confirm-delete'
45+
import { confirmDelete } from 'app/stores/confirm-delete'
4546

4647
const EmptyState = ({ onClick }: { onClick: () => void }) => (
4748
<TableEmptyBox>

0 commit comments

Comments
 (0)