Skip to content

Commit 28f7e4f

Browse files
committed
external subnets UI
1 parent 34bbf64 commit 28f7e4f

22 files changed

Lines changed: 1426 additions & 13 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ 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 ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
3536
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>
3637

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

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/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)

app/layouts/ProjectLayoutBase.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
Networking16Icon,
2020
Snapshots16Icon,
2121
Storage16Icon,
22+
Subnet16Icon,
2223
} from '@oxide/design-system/icons/react'
2324

2425
import { TopBar } from '~/components/TopBar'
@@ -68,6 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
6869
{ value: 'Images', path: pb.projectImages(projectSelector) },
6970
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
7071
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
72+
{ value: 'External Subnets', path: pb.externalSubnets(projectSelector) },
7173
{ value: 'Affinity Groups', path: pb.affinity(projectSelector) },
7274
{ value: 'Project Access', path: pb.projectAccess(projectSelector) },
7375
]
@@ -112,6 +114,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
112114
<NavLinkItem to={pb.floatingIps(projectSelector)}>
113115
<IpGlobal16Icon /> Floating IPs
114116
</NavLinkItem>
117+
<NavLinkItem to={pb.externalSubnets(projectSelector)}>
118+
<Subnet16Icon /> External Subnets
119+
</NavLinkItem>
115120
<NavLinkItem to={pb.affinity(projectSelector)}>
116121
<Affinity16Icon /> Affinity Groups
117122
</NavLinkItem>

app/pages/project/disks/DisksPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export default function DisksPage() {
164164
colHelper.accessor(
165165
(disk) => ('instance' in disk.state ? disk.state.instance : undefined),
166166
{
167-
header: 'Attached to',
167+
header: 'Instance',
168168
cell: (info) => (
169169
<InstanceLink instanceId={info.getValue()} tab="storage" cell />
170170
),

0 commit comments

Comments
 (0)