Skip to content

Commit cf41314

Browse files
committed
external subnets UI (initial attempt)
1 parent 948c1d3 commit cf41314

23 files changed

+1294
-15
lines changed

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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 ExternalSubnetCreate } from '@oxide/api'
12+
13+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
14+
import { ListboxField } from '~/components/form/fields/ListboxField'
15+
import { NameField } from '~/components/form/fields/NameField'
16+
import { NumberField } from '~/components/form/fields/NumberField'
17+
import { RadioField } from '~/components/form/fields/RadioField'
18+
import { TextField } from '~/components/form/fields/TextField'
19+
import { SideModalForm } from '~/components/form/SideModalForm'
20+
import { HL } from '~/components/HL'
21+
import { titleCrumb } from '~/hooks/use-crumbs'
22+
import { useProjectSelector } from '~/hooks/use-params'
23+
import { addToast } from '~/stores/toast'
24+
import { pb } from '~/util/path-builder'
25+
26+
export const handle = titleCrumb('New External Subnet')
27+
28+
export default function CreateExternalSubnetSideModalForm() {
29+
const projectSelector = useProjectSelector()
30+
const navigate = useNavigate()
31+
32+
const createExternalSubnet = useApiMutation(api.externalSubnetCreate, {
33+
onSuccess(subnet) {
34+
queryClient.invalidateEndpoint('externalSubnetList')
35+
// prettier-ignore
36+
addToast(<>External subnet <HL>{subnet.name}</HL> created</>)
37+
navigate(pb.externalSubnets(projectSelector))
38+
},
39+
})
40+
41+
const form = useForm({
42+
defaultValues: {
43+
name: '',
44+
description: '',
45+
allocationType: 'auto' as 'auto' | 'explicit',
46+
prefixLen: 24,
47+
pool: '',
48+
subnet: '',
49+
},
50+
})
51+
52+
const allocationType = form.watch('allocationType')
53+
54+
return (
55+
<SideModalForm
56+
form={form}
57+
formType="create"
58+
resourceName="external subnet"
59+
onDismiss={() => navigate(pb.externalSubnets(projectSelector))}
60+
onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => {
61+
const body: ExternalSubnetCreate =
62+
allocationType === 'explicit'
63+
? { name, description, allocator: { type: 'explicit', subnet } }
64+
: {
65+
name,
66+
description,
67+
allocator: {
68+
type: 'auto',
69+
prefixLen,
70+
poolSelector: pool ? { type: 'explicit', pool } : undefined,
71+
},
72+
}
73+
createExternalSubnet.mutate({ query: projectSelector, body })
74+
}}
75+
loading={createExternalSubnet.isPending}
76+
submitError={createExternalSubnet.error}
77+
>
78+
<NameField name="name" control={form.control} />
79+
<DescriptionField name="description" control={form.control} />
80+
<RadioField
81+
name="allocationType"
82+
label="Allocation method"
83+
control={form.control}
84+
items={[
85+
{ value: 'auto', label: 'Auto' },
86+
{ value: 'explicit', label: 'Explicit' },
87+
]}
88+
/>
89+
{allocationType === 'auto' ? (
90+
<>
91+
<NumberField
92+
name="prefixLen"
93+
label="Prefix length"
94+
required
95+
control={form.control}
96+
min={8}
97+
max={32}
98+
description="The prefix length for the allocated subnet (e.g., 24 for a /24). Minimum 8."
99+
/>
100+
{/* Subnet pool list endpoint not yet available
101+
https://github.com/oxidecomputer/omicron/issues/9814 */}
102+
<ListboxField
103+
name="pool"
104+
label="Subnet pool"
105+
control={form.control}
106+
placeholder="Default"
107+
noItemsPlaceholder="No pools linked to silo"
108+
items={[]}
109+
description="Subnet pool to allocate from. If not selected, the silo default is used."
110+
/>
111+
</>
112+
) : (
113+
<TextField
114+
name="subnet"
115+
label="Subnet CIDR"
116+
required
117+
control={form.control}
118+
description="The subnet to reserve, e.g., 10.128.1.0/24"
119+
/>
120+
)}
121+
</SideModalForm>
122+
)
123+
}

app/forms/external-subnet-edit.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
getListQFn,
14+
q,
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 { EmptyCell } from '~/table/cells/EmptyCell'
28+
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
29+
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
30+
import { ALL_ISH } from '~/util/consts'
31+
import { pb } from '~/util/path-builder'
32+
33+
const externalSubnetView = ({
34+
project,
35+
externalSubnet,
36+
}: {
37+
project: string
38+
externalSubnet: string
39+
}) =>
40+
q(api.externalSubnetView, {
41+
path: { externalSubnet },
42+
query: { project },
43+
})
44+
45+
const instanceList = (project: string) =>
46+
getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } })
47+
48+
export async function clientLoader({ params }: LoaderFunctionArgs) {
49+
const selector = getExternalSubnetSelector(params)
50+
await Promise.all([
51+
queryClient.fetchQuery(externalSubnetView(selector)),
52+
queryClient.fetchQuery(instanceList(selector.project).optionsFn()),
53+
])
54+
return null
55+
}
56+
57+
export const handle = titleCrumb('Edit External Subnet')
58+
59+
export default function EditExternalSubnetSideModalForm() {
60+
const navigate = useNavigate()
61+
62+
const subnetSelector = useExternalSubnetSelector()
63+
const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project }))
64+
65+
const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector))
66+
const { data: instances } = usePrefetchedQuery(
67+
instanceList(subnetSelector.project).optionsFn()
68+
)
69+
const instanceName = instances.items.find((i) => i.id === subnet.instanceId)?.name
70+
71+
const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, {
72+
onSuccess(updated) {
73+
queryClient.invalidateEndpoint('externalSubnetList')
74+
// prettier-ignore
75+
addToast(<>External subnet <HL>{updated.name}</HL> updated</>)
76+
onDismiss()
77+
},
78+
})
79+
80+
const form = useForm({ defaultValues: subnet })
81+
return (
82+
<SideModalForm
83+
form={form}
84+
formType="edit"
85+
resourceName="external subnet"
86+
onDismiss={onDismiss}
87+
onSubmit={({ name, description }) => {
88+
editExternalSubnet.mutate({
89+
path: { externalSubnet: subnetSelector.externalSubnet },
90+
query: { project: subnetSelector.project },
91+
body: { name, description },
92+
})
93+
}}
94+
loading={editExternalSubnet.isPending}
95+
submitError={editExternalSubnet.error}
96+
>
97+
<PropertiesTable>
98+
<PropertiesTable.IdRow id={subnet.id} />
99+
<PropertiesTable.DateRow label="Created" date={subnet.timeCreated} />
100+
<PropertiesTable.DateRow label="Updated" date={subnet.timeModified} />
101+
<PropertiesTable.Row label="Subnet">{subnet.subnet}</PropertiesTable.Row>
102+
<PropertiesTable.Row label="Instance">
103+
{instanceName ? (
104+
<InstanceLinkCell instanceId={subnet.instanceId} />
105+
) : (
106+
<EmptyCell />
107+
)}
108+
</PropertiesTable.Row>
109+
</PropertiesTable>
110+
<NameField name="name" control={form.control} />
111+
<DescriptionField name="description" control={form.control} />
112+
</SideModalForm>
113+
)
114+
}

app/forms/firewall-rules-common.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
487487
control={protocolForm.control}
488488
description={
489489
<>
490-
Enter a code (0) or range (e.g. 1&ndash;3). Leave blank for all
490+
Enter a code (0) or range (e.g., 1&ndash;3). Leave blank for all
491491
traffic of type {selectedIcmpType}.
492492
</>
493493
}

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
6868
{ value: 'Snapshots', path: pb.snapshots(projectSelector) },
6969
{ value: 'Images', path: pb.projectImages(projectSelector) },
7070
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
71+
{ value: 'External Subnets', path: pb.externalSubnets(projectSelector) },
7172
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
7273
{ value: 'Affinity Groups', path: pb.affinity(projectSelector) },
7374
{ value: 'Project Access', path: pb.projectAccess(projectSelector) },
@@ -111,6 +112,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
111112
<NavLinkItem to={pb.vpcs(projectSelector)}>
112113
<Networking16Icon /> VPCs
113114
</NavLinkItem>
115+
<NavLinkItem to={pb.externalSubnets(projectSelector)}>
116+
<Networking16Icon /> External Subnets
117+
</NavLinkItem>
114118
<NavLinkItem to={pb.floatingIps(projectSelector)}>
115119
<IpGlobal16Icon /> Floating IPs
116120
</NavLinkItem>

0 commit comments

Comments
 (0)