Skip to content

Commit a72a242

Browse files
authored
Operator subnet pools UI (#3146)
Closes #3120 WIP, still need to go through it all. But being able to follow the model of IP pools meant the robot could slam through this. <img width="1246" height="945" alt="image" src="https://github.com/user-attachments/assets/6ebeb798-fe8e-4540-9de9-305962b251cd" /> <img width="1242" height="909" alt="image" src="https://github.com/user-attachments/assets/2ccf0fb9-d6a8-485e-8bf8-549b117040a2" />
1 parent 8e240f1 commit a72a242

24 files changed

Lines changed: 2228 additions & 46 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/ip-pool-range-add.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8-
import { useCallback } from 'react'
98
import { useForm, type FieldErrors } from 'react-hook-form'
109
import { useNavigate } from 'react-router'
1110

@@ -35,6 +34,11 @@ const defaultValues: IpRange = {
3534
last: '',
3635
}
3736

37+
// Using a resolver overrides all field-level validation (required, min, max,
38+
// etc.), so this function must cover everything. Field-level `required` props
39+
// still affect UI display (hiding the "optional" label) but are inert for
40+
// validation.
41+
3842
/**
3943
* Validates IP range addresses against the pool's IP version.
4044
* Ensures both addresses are valid IPs and match the pool's version.
@@ -103,13 +107,10 @@ export default function IpPoolAddRange() {
103107
},
104108
})
105109

106-
// Derive pool version at validation time to ensure correct IP version rules
107-
const resolver = useCallback(
108-
(values: IpRange) => createResolver(poolData?.ipVersion ?? 'v4')(values),
109-
[poolData?.ipVersion]
110-
)
111-
112-
const form = useForm({ defaultValues, resolver })
110+
const form = useForm({
111+
defaultValues,
112+
resolver: createResolver(poolData.ipVersion),
113+
})
113114

114115
return (
115116
<SideModalForm

app/forms/subnet-pool-create.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
<SubnetPoolVisibilityMessage />
62+
<NameField name="name" control={form.control} />
63+
<DescriptionField name="description" control={form.control} />
64+
<RadioField
65+
name="ipVersion"
66+
label="IP version"
67+
column
68+
control={form.control}
69+
items={[
70+
{ value: 'v4', label: 'v4' },
71+
{ value: 'v6', label: 'v6' },
72+
]}
73+
/>
74+
<SideModalFormDocs docs={[docLinks.subnetPools]} />
75+
</SideModalForm>
76+
)
77+
}
78+
79+
export const SubnetPoolVisibilityMessage = () => (
80+
<Message
81+
variant="info"
82+
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
83+
/>
84+
)

app/forms/subnet-pool-edit.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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 { SideModalFormDocs } from '~/ui/lib/ModalLinks'
22+
import { docLinks } from '~/util/links'
23+
import { pb } from '~/util/path-builder'
24+
import type * as PP from '~/util/path-params'
25+
26+
import { SubnetPoolVisibilityMessage } from './subnet-pool-create'
27+
28+
const subnetPoolView = ({ subnetPool }: PP.SubnetPool) =>
29+
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })
30+
31+
export async function clientLoader({ params }: LoaderFunctionArgs) {
32+
const selector = getSubnetPoolSelector(params)
33+
await queryClient.prefetchQuery(subnetPoolView(selector))
34+
return null
35+
}
36+
37+
export const handle = makeCrumb('Edit subnet pool')
38+
39+
export default function EditSubnetPoolSideModalForm() {
40+
const navigate = useNavigate()
41+
const poolSelector = useSubnetPoolSelector()
42+
43+
const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector))
44+
45+
const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })
46+
47+
const editPool = useApiMutation(api.systemSubnetPoolUpdate, {
48+
onSuccess(updatedPool) {
49+
queryClient.invalidateEndpoint('systemSubnetPoolList')
50+
navigate(pb.subnetPool({ subnetPool: updatedPool.name }))
51+
// prettier-ignore
52+
addToast(<>Subnet pool <HL>{updatedPool.name}</HL> updated</>)
53+
54+
if (pool.name === updatedPool.name) {
55+
queryClient.invalidateEndpoint('systemSubnetPoolView')
56+
}
57+
},
58+
})
59+
60+
return (
61+
<SideModalForm
62+
form={form}
63+
formType="edit"
64+
resourceName="subnet pool"
65+
onDismiss={() => navigate(pb.subnetPool({ subnetPool: poolSelector.subnetPool }))}
66+
onSubmit={({ name, description }) => {
67+
editPool.mutate({
68+
path: { pool: poolSelector.subnetPool },
69+
body: { name, description },
70+
})
71+
}}
72+
loading={editPool.isPending}
73+
submitError={editPool.error}
74+
>
75+
<SubnetPoolVisibilityMessage />
76+
<NameField name="name" control={form.control} />
77+
<DescriptionField name="description" control={form.control} />
78+
<SideModalFormDocs docs={[docLinks.subnetPools]} />
79+
</SideModalForm>
80+
)
81+
}
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)