Skip to content

Commit 24ba197

Browse files
committed
operator subnet pools UI
1 parent 5768e1e commit 24ba197

23 files changed

Lines changed: 2219 additions & 38 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: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 { describe, expect, it } from 'vitest'
9+
10+
import { createResolver } from './subnet-pool-member-add'
11+
12+
const resolve = createResolver('v4')
13+
const resolve6 = createResolver('v6')
14+
15+
const valid = { subnet: '10.0.0.0/16', minPrefixLength: 20, maxPrefixLength: 28 }
16+
17+
type Result = ReturnType<typeof resolve>
18+
19+
function errMsg(result: Result, field: keyof Result['errors']) {
20+
return result.errors[field]?.message
21+
}
22+
23+
describe('createResolver', () => {
24+
it('accepts valid v4 input', () => {
25+
expect(Object.keys(resolve(valid).errors)).toEqual([])
26+
})
27+
28+
it('accepts valid v6 input', () => {
29+
const result = resolve6({
30+
subnet: 'fd00:1000::/32',
31+
minPrefixLength: 48,
32+
maxPrefixLength: 64,
33+
})
34+
expect(Object.keys(result.errors)).toEqual([])
35+
})
36+
37+
it('accepts omitted prefix lengths', () => {
38+
const result = resolve({
39+
subnet: '10.0.0.0/16',
40+
minPrefixLength: NaN,
41+
maxPrefixLength: NaN,
42+
})
43+
expect(Object.keys(result.errors)).toEqual([])
44+
})
45+
46+
it('rejects invalid CIDR', () => {
47+
const result = resolve({ ...valid, subnet: 'not-a-cidr' })
48+
expect(errMsg(result, 'subnet')).toMatch(/IP address/)
49+
})
50+
51+
it('rejects v6 subnet in v4 pool', () => {
52+
const result = resolve({ ...valid, subnet: 'fd00::/32' })
53+
expect(errMsg(result, 'subnet')).toBe('IPv6 subnet not allowed in IPv4 pool')
54+
})
55+
56+
it('rejects v4 subnet in v6 pool', () => {
57+
const result = resolve6({ ...valid, subnet: '10.0.0.0/16' })
58+
expect(errMsg(result, 'subnet')).toBe('IPv4 subnet not allowed in IPv6 pool')
59+
})
60+
61+
it('rejects min > max prefix length', () => {
62+
const result = resolve({ ...valid, minPrefixLength: 28, maxPrefixLength: 20 })
63+
expect(errMsg(result, 'minPrefixLength')).toMatch(//)
64+
})
65+
66+
it('rejects min prefix length < subnet width', () => {
67+
const result = resolve({ ...valid, minPrefixLength: 8 })
68+
expect(errMsg(result, 'minPrefixLength')).toMatch(/ subnet prefix length \(16\)/)
69+
})
70+
71+
it('rejects max prefix length < subnet width', () => {
72+
const result = resolve({ ...valid, maxPrefixLength: 8 })
73+
expect(errMsg(result, 'maxPrefixLength')).toMatch(/ subnet prefix length \(16\)/)
74+
})
75+
76+
it('rejects prefix length above max bound (v4: 32)', () => {
77+
const result = resolve({ ...valid, minPrefixLength: 33 })
78+
expect(errMsg(result, 'minPrefixLength')).toBe('Must be between 0 and 32')
79+
})
80+
81+
it('rejects prefix length below 0', () => {
82+
const result = resolve({ ...valid, maxPrefixLength: -1 })
83+
expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 32')
84+
})
85+
86+
it('shows min-≤-max error even when min is also below subnet width', () => {
87+
// min(12) > max(10) AND min(12) < subnetWidth(16): the min-≤-max error
88+
// should take priority over the subnet-width error
89+
const result = resolve({ ...valid, minPrefixLength: 12, maxPrefixLength: 10 })
90+
expect(errMsg(result, 'minPrefixLength')).toMatch(//)
91+
})
92+
93+
it('rejects prefix length above max bound (v6: 128)', () => {
94+
const result = resolve6({
95+
subnet: 'fd00::/32',
96+
minPrefixLength: 48,
97+
maxPrefixLength: 200,
98+
})
99+
expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 128')
100+
})
101+
})

0 commit comments

Comments
 (0)