Skip to content

Commit 817c209

Browse files
committed
operator subnet pools UI
1 parent 85b8cf8 commit 817c209

22 files changed

Lines changed: 1815 additions & 32 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
- Store API response objects in the mock tables when possible so state persists across calls.
6666
- Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`).
6767
- All UUIDs in `mock-api/` must be valid RFC 4122 (a safety test enforces this). Use `uuidgen` to generate them—do not hand-write UUIDs.
68+
- MSW starts fresh with a new db on every page load, so in E2E tests, use client-side navigation (click links/breadcrumbs) after mutations instead of `page.goto` to preserve db state within a test.
6869

6970
# Routing
7071

app/forms/ip-pool-edit.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import { useForm } from 'react-hook-form'
99
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
10+
import * as R from 'remeda'
1011

1112
import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'
1213

@@ -40,7 +41,7 @@ export default function EditIpPoolSideModalForm() {
4041

4142
const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))
4243

43-
const form = useForm({ defaultValues: pool })
44+
const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })
4445

4546
const editPool = useApiMutation(api.systemIpPoolUpdate, {
4647
onSuccess(updatedPool) {

app/forms/subnet-pool-create.tsx

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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 { useForm } from 'react-hook-form'
9+
import { useNavigate } from 'react-router'
10+
11+
import { api, queryClient, useApiMutation, type SubnetPoolCreate } from '@oxide/api'
12+
13+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
14+
import { NameField } from '~/components/form/fields/NameField'
15+
import { RadioField } from '~/components/form/fields/RadioField'
16+
import { SideModalForm } from '~/components/form/SideModalForm'
17+
import { HL } from '~/components/HL'
18+
import { titleCrumb } from '~/hooks/use-crumbs'
19+
import { addToast } from '~/stores/toast'
20+
import { Message } from '~/ui/lib/Message'
21+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
22+
import { docLinks } from '~/util/links'
23+
import { pb } from '~/util/path-builder'
24+
25+
const defaultValues: SubnetPoolCreate = {
26+
name: '',
27+
description: '',
28+
ipVersion: 'v4',
29+
}
30+
31+
export const handle = titleCrumb('New subnet pool')
32+
33+
export default function CreateSubnetPoolSideModalForm() {
34+
const navigate = useNavigate()
35+
36+
const onDismiss = () => navigate(pb.subnetPools())
37+
38+
const createPool = useApiMutation(api.systemSubnetPoolCreate, {
39+
onSuccess(_pool) {
40+
queryClient.invalidateEndpoint('systemSubnetPoolList')
41+
// prettier-ignore
42+
addToast(<>Subnet pool <HL>{_pool.name}</HL> created</>)
43+
navigate(pb.subnetPools())
44+
},
45+
})
46+
47+
const form = useForm<SubnetPoolCreate>({ defaultValues })
48+
49+
return (
50+
<SideModalForm
51+
form={form}
52+
formType="create"
53+
resourceName="subnet pool"
54+
onDismiss={onDismiss}
55+
onSubmit={({ name, description, ipVersion }) => {
56+
createPool.mutate({ body: { name, description, ipVersion } })
57+
}}
58+
loading={createPool.isPending}
59+
submitError={createPool.error}
60+
>
61+
<Message
62+
variant="info"
63+
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
64+
/>
65+
<NameField name="name" control={form.control} />
66+
<DescriptionField name="description" control={form.control} />
67+
<RadioField
68+
name="ipVersion"
69+
label="IP version"
70+
column
71+
control={form.control}
72+
items={[
73+
{ value: 'v4', label: 'v4' },
74+
{ value: 'v6', label: 'v6' },
75+
]}
76+
/>
77+
<SideModalFormDocs docs={[docLinks.subnetPools]} />
78+
</SideModalForm>
79+
)
80+
}

app/forms/subnet-pool-edit.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 { useForm } from 'react-hook-form'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
10+
import * as R from 'remeda'
11+
12+
import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'
13+
14+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
15+
import { NameField } from '~/components/form/fields/NameField'
16+
import { SideModalForm } from '~/components/form/SideModalForm'
17+
import { HL } from '~/components/HL'
18+
import { makeCrumb } from '~/hooks/use-crumbs'
19+
import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params'
20+
import { addToast } from '~/stores/toast'
21+
import { Message } from '~/ui/lib/Message'
22+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
23+
import { docLinks } from '~/util/links'
24+
import { pb } from '~/util/path-builder'
25+
import type * as PP from '~/util/path-params'
26+
27+
const subnetPoolView = ({ subnetPool }: PP.SubnetPool) =>
28+
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })
29+
30+
export async function clientLoader({ params }: LoaderFunctionArgs) {
31+
const selector = getSubnetPoolSelector(params)
32+
await queryClient.prefetchQuery(subnetPoolView(selector))
33+
return null
34+
}
35+
36+
export const handle = makeCrumb('Edit subnet pool')
37+
38+
export default function EditSubnetPoolSideModalForm() {
39+
const navigate = useNavigate()
40+
const poolSelector = useSubnetPoolSelector()
41+
42+
const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector))
43+
44+
const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })
45+
46+
const editPool = useApiMutation(api.systemSubnetPoolUpdate, {
47+
onSuccess(updatedPool) {
48+
queryClient.invalidateEndpoint('systemSubnetPoolList')
49+
navigate(pb.subnetPool({ subnetPool: updatedPool.name }))
50+
// prettier-ignore
51+
addToast(<>Subnet pool <HL>{updatedPool.name}</HL> updated</>)
52+
53+
if (pool.name === updatedPool.name) {
54+
queryClient.invalidateEndpoint('systemSubnetPoolView')
55+
}
56+
},
57+
})
58+
59+
return (
60+
<SideModalForm
61+
form={form}
62+
formType="edit"
63+
resourceName="subnet pool"
64+
onDismiss={() => navigate(pb.subnetPool({ subnetPool: poolSelector.subnetPool }))}
65+
onSubmit={({ name, description }) => {
66+
editPool.mutate({
67+
path: { pool: poolSelector.subnetPool },
68+
body: { name, description },
69+
})
70+
}}
71+
loading={editPool.isPending}
72+
submitError={editPool.error}
73+
>
74+
<Message
75+
variant="info"
76+
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
77+
/>
78+
<NameField name="name" control={form.control} />
79+
<DescriptionField name="description" control={form.control} />
80+
<SideModalFormDocs docs={[docLinks.subnetPools]} />
81+
</SideModalForm>
82+
)
83+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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 { useForm } from 'react-hook-form'
9+
import { useNavigate } from 'react-router'
10+
11+
import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'
12+
13+
import { NumberField } from '~/components/form/fields/NumberField'
14+
import { TextField } from '~/components/form/fields/TextField'
15+
import { SideModalForm } from '~/components/form/SideModalForm'
16+
import { titleCrumb } from '~/hooks/use-crumbs'
17+
import { useSubnetPoolSelector } from '~/hooks/use-params'
18+
import { addToast } from '~/stores/toast'
19+
import { Message } from '~/ui/lib/Message'
20+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
21+
import { docLinks } from '~/util/links'
22+
import { pb } from '~/util/path-builder'
23+
24+
type MemberAddForm = {
25+
subnet: string
26+
minPrefixLength: number
27+
maxPrefixLength: number
28+
}
29+
30+
const defaultValues: MemberAddForm = {
31+
subnet: '',
32+
minPrefixLength: NaN,
33+
maxPrefixLength: NaN,
34+
}
35+
36+
export const handle = titleCrumb('Add Member')
37+
38+
export default function SubnetPoolMemberAdd() {
39+
const { subnetPool } = useSubnetPoolSelector()
40+
const navigate = useNavigate()
41+
42+
const { data: poolData } = usePrefetchedQuery(
43+
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })
44+
)
45+
46+
const onDismiss = () => navigate(pb.subnetPool({ subnetPool }))
47+
48+
const addMember = useApiMutation(api.systemSubnetPoolMemberAdd, {
49+
onSuccess() {
50+
queryClient.invalidateEndpoint('systemSubnetPoolMemberList')
51+
addToast({ content: 'Member added' })
52+
onDismiss()
53+
},
54+
})
55+
56+
const form = useForm<MemberAddForm>({ defaultValues })
57+
58+
const maxBound = poolData.ipVersion === 'v4' ? 32 : 128
59+
60+
return (
61+
<SideModalForm
62+
form={form}
63+
formType="create"
64+
resourceName="member"
65+
title="Add member"
66+
onDismiss={onDismiss}
67+
onSubmit={({ subnet, minPrefixLength, maxPrefixLength }) => {
68+
addMember.mutate({
69+
path: { pool: subnetPool },
70+
body: {
71+
subnet,
72+
minPrefixLength: Number.isNaN(minPrefixLength) ? undefined : minPrefixLength,
73+
maxPrefixLength: Number.isNaN(maxPrefixLength) ? undefined : maxPrefixLength,
74+
},
75+
})
76+
}}
77+
loading={addMember.isPending}
78+
submitError={addMember.error}
79+
>
80+
<Message
81+
variant="info"
82+
content={`This pool uses IP${poolData.ipVersion} addresses. Prefix lengths must be between 0 and ${maxBound}.`}
83+
/>
84+
{/* TODO: validate CIDR syntax, IP version match with pool, and
85+
min/max prefix length relative to subnet prefix */}
86+
<TextField
87+
name="subnet"
88+
label="Subnet"
89+
description="CIDR notation (e.g., 10.0.0.0/16)"
90+
control={form.control}
91+
required
92+
/>
93+
<NumberField
94+
name="minPrefixLength"
95+
label="Min prefix length"
96+
description={`Minimum prefix length for allocations (0–${maxBound})`}
97+
control={form.control}
98+
min={0}
99+
max={maxBound}
100+
/>
101+
<NumberField
102+
name="maxPrefixLength"
103+
label="Max prefix length"
104+
description={`Maximum prefix length for allocations (0–${maxBound})`}
105+
control={form.control}
106+
min={0}
107+
max={maxBound}
108+
/>
109+
<SideModalFormDocs docs={[docLinks.subnetPools]} />
110+
</SideModalForm>
111+
)
112+
}

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
5252
export const requireSledParams = requireParams('sledId')
5353
export const requireUpdateParams = requireParams('version')
5454
export const getIpPoolSelector = requireParams('pool')
55+
export const getSubnetPoolSelector = requireParams('subnetPool')
5556
export const getAffinityGroupSelector = requireParams('project', 'affinityGroup')
5657
export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup')
5758

@@ -102,6 +103,7 @@ export const useIdpSelector = () => useSelectedParams(getIdpSelector)
102103
export const useSledParams = () => useSelectedParams(requireSledParams)
103104
export const useUpdateParams = () => useSelectedParams(requireUpdateParams)
104105
export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector)
106+
export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector)
105107
export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector)
106108
export const useAntiAffinityGroupSelector = () =>
107109
useSelectedParams(getAntiAffinityGroupSelector)

app/layouts/SystemLayout.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Metrics16Icon,
1616
Servers16Icon,
1717
SoftwareUpdate16Icon,
18+
Subnet16Icon,
1819
} from '@oxide/design-system/icons/react'
1920

2021
import { trigger404 } from '~/components/ErrorBoundary'
@@ -53,6 +54,7 @@ export default function SystemLayout() {
5354
{ value: 'Utilization', path: pb.systemUtilization() },
5455
{ value: 'Inventory', path: pb.sledInventory() },
5556
{ value: 'IP Pools', path: pb.ipPools() },
57+
{ value: 'Subnet Pools', path: pb.subnetPools() },
5658
{ value: 'System Update', path: pb.systemUpdate() },
5759
{ value: 'Fleet Access', path: pb.fleetAccess() },
5860
]
@@ -96,6 +98,9 @@ export default function SystemLayout() {
9698
<NavLinkItem to={pb.ipPools()}>
9799
<IpGlobal16Icon /> IP Pools
98100
</NavLinkItem>
101+
<NavLinkItem to={pb.subnetPools()}>
102+
<Subnet16Icon /> Subnet Pools
103+
</NavLinkItem>
99104
<NavLinkItem to={pb.systemUpdate()}>
100105
<SoftwareUpdate16Icon /> System Update
101106
</NavLinkItem>

0 commit comments

Comments
 (0)