Skip to content

Commit 85b8cf8

Browse files
committed
external subnets UI
1 parent 34bbf64 commit 85b8cf8

26 files changed

Lines changed: 1477 additions & 22 deletions

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
- Only implement what is necessary to exercise the UI; keep the db seeded via `mock-api/msw/db.ts`.
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`).
67+
- 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.
6768

6869
# Routing
6970

app/api/selectors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type SystemUpdate = Readonly<{ version: string }>
3232
export type SshKey = Readonly<{ sshKey: string }>
3333
export type Sled = Readonly<{ sledId?: string }>
3434
export type IpPool = Readonly<{ pool?: string }>
35+
export type SubnetPool = Readonly<{ subnetPool?: string }>
36+
export type ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
3537
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>
3638

3739
export type Id = Readonly<{ id: string }>

app/components/AttachEphemeralIpModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '~/api'
2323
import { ListboxField } from '~/components/form/fields/ListboxField'
2424
import { HL } from '~/components/HL'
25-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
25+
import { toPoolItem } from '~/components/PoolListboxItem'
2626
import { useInstanceSelector } from '~/hooks/use-params'
2727
import { addToast } from '~/stores/toast'
2828
import { Message } from '~/ui/lib/Message'
@@ -90,7 +90,7 @@ export const AttachEphemeralIpModal = ({
9090
name="pool"
9191
label="Pool"
9292
control={form.control}
93-
items={sortPools(compatibleUnicastPools).map(toIpPoolItem)}
93+
items={sortPools(compatibleUnicastPools).map(toPoolItem)}
9494
disabled={compatibleUnicastPools.length === 0}
9595
placeholder="Select a pool"
9696
noItemsPlaceholder="No pools available"
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
import type { SiloIpPool } from '@oxide/api'
9+
import type { IpVersion } from '@oxide/api'
1010
import { Badge } from '@oxide/design-system/ui'
1111

1212
import { IpVersionBadge } from '~/components/IpVersionBadge'
1313
import type { ListboxItem } from '~/ui/lib/Listbox'
1414

15-
/** Format a SiloIpPool for use as a ListboxField item */
16-
export function toIpPoolItem(p: SiloIpPool): ListboxItem {
15+
/** Common fields of SiloIpPool and SiloSubnetPool used for display */
16+
type PoolLike = {
17+
name: string
18+
isDefault: boolean
19+
ipVersion: IpVersion
20+
description: string
21+
}
22+
23+
/** Format a pool for use as a ListboxField item */
24+
export function toPoolItem(p: PoolLike): ListboxItem {
1725
const value = p.name
1826
const selectedLabel = p.name
1927
const label = (
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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+
import { match } from 'ts-pattern'
11+
12+
import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'
13+
14+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
15+
import { ListboxField } from '~/components/form/fields/ListboxField'
16+
import { NameField } from '~/components/form/fields/NameField'
17+
import { NumberField } from '~/components/form/fields/NumberField'
18+
import { RadioField } from '~/components/form/fields/RadioField'
19+
import { TextField } from '~/components/form/fields/TextField'
20+
import { SideModalForm } from '~/components/form/SideModalForm'
21+
import { HL } from '~/components/HL'
22+
import { toPoolItem } from '~/components/PoolListboxItem'
23+
import { titleCrumb } from '~/hooks/use-crumbs'
24+
import { useProjectSelector } from '~/hooks/use-params'
25+
import { addToast } from '~/stores/toast'
26+
import { ALL_ISH } from '~/util/consts'
27+
import { pb } from '~/util/path-builder'
28+
29+
const poolList = q(api.subnetPoolList, { query: { limit: ALL_ISH } })
30+
31+
export async function clientLoader() {
32+
await queryClient.prefetchQuery(poolList)
33+
return null
34+
}
35+
36+
export const handle = titleCrumb('New External Subnet')
37+
38+
type FormValues = {
39+
name: string
40+
description: string
41+
allocationType: 'auto' | 'explicit'
42+
prefixLen: number
43+
pool: string
44+
subnet: string
45+
}
46+
47+
const defaultFormValues: Omit<FormValues, 'pool'> = {
48+
name: '',
49+
description: '',
50+
allocationType: 'auto',
51+
prefixLen: 24,
52+
subnet: '',
53+
}
54+
55+
export default function CreateExternalSubnetSideModalForm() {
56+
const { data: pools } = usePrefetchedQuery(poolList)
57+
58+
const defaultPool = pools.items.find((p) => p.isDefault)
59+
60+
const projectSelector = useProjectSelector()
61+
const navigate = useNavigate()
62+
63+
const createExternalSubnet = useApiMutation(api.externalSubnetCreate, {
64+
onSuccess(subnet) {
65+
queryClient.invalidateEndpoint('externalSubnetList')
66+
// prettier-ignore
67+
addToast(<>External subnet <HL>{subnet.name}</HL> created</>)
68+
navigate(pb.externalSubnets(projectSelector))
69+
},
70+
})
71+
72+
const form = useForm({
73+
defaultValues: { ...defaultFormValues, pool: defaultPool?.name ?? '' },
74+
})
75+
76+
const allocationType = form.watch('allocationType')
77+
78+
return (
79+
<SideModalForm
80+
form={form}
81+
formType="create"
82+
resourceName="external subnet"
83+
onDismiss={() => navigate(pb.externalSubnets(projectSelector))}
84+
onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => {
85+
const allocator = match(allocationType)
86+
.with('explicit', () => ({ type: 'explicit' as const, subnet }))
87+
.with('auto', () => ({
88+
type: 'auto' as const,
89+
prefixLen,
90+
poolSelector: { type: 'explicit' as const, pool },
91+
}))
92+
.exhaustive()
93+
createExternalSubnet.mutate({
94+
query: projectSelector,
95+
body: { name, description, allocator },
96+
})
97+
}}
98+
loading={createExternalSubnet.isPending}
99+
submitError={createExternalSubnet.error}
100+
>
101+
<NameField name="name" control={form.control} />
102+
<DescriptionField name="description" control={form.control} />
103+
<RadioField
104+
name="allocationType"
105+
label="Allocation method"
106+
control={form.control}
107+
items={[
108+
{ value: 'auto', label: 'Auto' },
109+
{ value: 'explicit', label: 'Explicit' },
110+
]}
111+
/>
112+
{allocationType === 'auto' ? (
113+
<>
114+
<NumberField
115+
name="prefixLen"
116+
label="Prefix length"
117+
required
118+
control={form.control}
119+
// TODO: these min and max are wrong! Pools have an IP version and the min and max depend on that.
120+
min={8}
121+
max={32}
122+
description="The prefix length for the allocated subnet (e.g., 24 for a /24). Minimum 8."
123+
/>
124+
<ListboxField
125+
name="pool"
126+
label="Subnet pool"
127+
control={form.control}
128+
placeholder="Select a pool"
129+
noItemsPlaceholder="No pools linked to silo"
130+
items={pools.items.map(toPoolItem)}
131+
required
132+
description="Subnet pool to allocate from"
133+
/>
134+
</>
135+
) : (
136+
<TextField
137+
name="subnet"
138+
label="Subnet CIDR"
139+
required
140+
control={form.control}
141+
description="The subnet to reserve, e.g., 10.128.1.0/24"
142+
/>
143+
)}
144+
</SideModalForm>
145+
)
146+
}

app/forms/external-subnet-edit.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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+
11+
import {
12+
api,
13+
q,
14+
qErrorsAllowed,
15+
queryClient,
16+
useApiMutation,
17+
usePrefetchedQuery,
18+
} from '@oxide/api'
19+
20+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
21+
import { NameField } from '~/components/form/fields/NameField'
22+
import { SideModalForm } from '~/components/form/SideModalForm'
23+
import { HL } from '~/components/HL'
24+
import { titleCrumb } from '~/hooks/use-crumbs'
25+
import { getExternalSubnetSelector, useExternalSubnetSelector } from '~/hooks/use-params'
26+
import { addToast } from '~/stores/toast'
27+
import { InstanceLink } from '~/table/cells/InstanceLinkCell'
28+
import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell'
29+
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
30+
import { pb } from '~/util/path-builder'
31+
import type * as PP from '~/util/path-params'
32+
33+
const externalSubnetView = ({ project, externalSubnet }: PP.ExternalSubnet) =>
34+
q(api.externalSubnetView, {
35+
path: { externalSubnet },
36+
query: { project },
37+
})
38+
39+
export async function clientLoader({ params }: LoaderFunctionArgs) {
40+
const selector = getExternalSubnetSelector(params)
41+
const subnet = await queryClient.fetchQuery(externalSubnetView(selector))
42+
await Promise.all([
43+
queryClient.prefetchQuery(
44+
// subnet pool cell uses errors allowed, so we have to do that here to match
45+
qErrorsAllowed(
46+
api.subnetPoolView,
47+
{ path: { pool: subnet.subnetPoolId } },
48+
{
49+
errorsExpected: {
50+
explanation: 'the referenced subnet pool may have been deleted.',
51+
statusCode: 404,
52+
},
53+
}
54+
)
55+
),
56+
subnet.instanceId
57+
? queryClient.prefetchQuery(
58+
q(api.instanceView, { path: { instance: subnet.instanceId } })
59+
)
60+
: null,
61+
])
62+
return null
63+
}
64+
65+
export const handle = titleCrumb('Edit External Subnet')
66+
67+
export default function EditExternalSubnetSideModalForm() {
68+
const navigate = useNavigate()
69+
70+
const subnetSelector = useExternalSubnetSelector()
71+
const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project }))
72+
73+
const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector))
74+
75+
const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, {
76+
onSuccess(updated) {
77+
queryClient.invalidateEndpoint('externalSubnetList')
78+
// prettier-ignore
79+
addToast(<>External subnet <HL>{updated.name}</HL> updated</>)
80+
onDismiss()
81+
},
82+
})
83+
84+
const form = useForm({ defaultValues: subnet })
85+
return (
86+
<SideModalForm
87+
form={form}
88+
formType="edit"
89+
resourceName="external subnet"
90+
onDismiss={onDismiss}
91+
onSubmit={({ name, description }) => {
92+
editExternalSubnet.mutate({
93+
path: { externalSubnet: subnetSelector.externalSubnet },
94+
query: { project: subnetSelector.project },
95+
body: { name, description },
96+
})
97+
}}
98+
loading={editExternalSubnet.isPending}
99+
submitError={editExternalSubnet.error}
100+
>
101+
<PropertiesTable>
102+
<PropertiesTable.IdRow id={subnet.id} />
103+
<PropertiesTable.DateRow label="Created" date={subnet.timeCreated} />
104+
<PropertiesTable.DateRow label="Updated" date={subnet.timeModified} />
105+
<PropertiesTable.Row label="Subnet">{subnet.subnet}</PropertiesTable.Row>
106+
<PropertiesTable.Row label="Subnet Pool">
107+
<SubnetPoolCell subnetPoolId={subnet.subnetPoolId} />
108+
</PropertiesTable.Row>
109+
<PropertiesTable.Row label="Instance">
110+
<InstanceLink instanceId={subnet.instanceId} tab="networking" />
111+
</PropertiesTable.Row>
112+
</PropertiesTable>
113+
<NameField name="name" control={form.control} />
114+
<DescriptionField name="description" control={form.control} />
115+
{/* TODO: add SideModalFormDocs when external subnet docs exist */}
116+
</SideModalForm>
117+
)
118+
}

app/forms/floating-ip-create.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
2525
import { NameField } from '~/components/form/fields/NameField'
2626
import { SideModalForm } from '~/components/form/SideModalForm'
2727
import { HL } from '~/components/HL'
28-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
28+
import { toPoolItem } from '~/components/PoolListboxItem'
2929
import { titleCrumb } from '~/hooks/use-crumbs'
3030
import { useProjectSelector } from '~/hooks/use-params'
3131
import { addToast } from '~/stores/toast'
@@ -103,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() {
103103
name="pool"
104104
label="Pool"
105105
control={form.control}
106-
items={sortPools(unicastPools).map(toIpPoolItem)}
106+
items={sortPools(unicastPools).map(toPoolItem)}
107107
required
108108
placeholder="Select a pool"
109109
noItemsPlaceholder="No pools available"

app/forms/instance-create.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField'
6363
import { Form } from '~/components/form/Form'
6464
import { FullPageForm } from '~/components/form/FullPageForm'
6565
import { HL } from '~/components/HL'
66-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
66+
import { toPoolItem } from '~/components/PoolListboxItem'
6767
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
6868
import { addToast } from '~/stores/toast'
6969
import { Button } from '~/ui/lib/Button'
@@ -335,7 +335,7 @@ function EphemeralIpCheckbox({
335335
<ListboxField
336336
name={poolFieldName}
337337
control={control}
338-
items={pools.map(toIpPoolItem)}
338+
items={pools.map(toPoolItem)}
339339
disabled={isSubmitting}
340340
required={checked}
341341
hideOptionalTag

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const requireParams =
3333
}
3434

3535
export const getProjectSelector = requireParams('project')
36+
export const getExternalSubnetSelector = requireParams('project', 'externalSubnet')
3637
export const getFloatingIpSelector = requireParams('project', 'floatingIp')
3738
export const getInstanceSelector = requireParams('project', 'instance')
3839
export const getVpcSelector = requireParams('project', 'vpc')
@@ -79,6 +80,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
7980
// params are present. Only the specified keys end up in the result object, but
8081
// we do not error if there are other params present in the query string.
8182

83+
export const useExternalSubnetSelector = () => useSelectedParams(getExternalSubnetSelector)
8284
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
8385
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
8486
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)

0 commit comments

Comments
 (0)