Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type SystemUpdate = Readonly<{ version: string }>
export type SshKey = Readonly<{ sshKey: string }>
export type Sled = Readonly<{ sledId?: string }>
export type IpPool = Readonly<{ pool?: string }>
export type ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>

export type Id = Readonly<{ id: string }>
123 changes: 123 additions & 0 deletions app/forms/external-subnet-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'
import { useNavigate } from 'react-router'

import { api, queryClient, useApiMutation, type ExternalSubnetCreate } from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { NumberField } from '~/components/form/fields/NumberField'
import { RadioField } from '~/components/form/fields/RadioField'
import { TextField } from '~/components/form/fields/TextField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { titleCrumb } from '~/hooks/use-crumbs'
import { useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { pb } from '~/util/path-builder'

export const handle = titleCrumb('New External Subnet')

export default function CreateExternalSubnetSideModalForm() {
const projectSelector = useProjectSelector()
const navigate = useNavigate()

const createExternalSubnet = useApiMutation(api.externalSubnetCreate, {
onSuccess(subnet) {
queryClient.invalidateEndpoint('externalSubnetList')
// prettier-ignore
addToast(<>External subnet <HL>{subnet.name}</HL> created</>)
navigate(pb.externalSubnets(projectSelector))
},
})

const form = useForm({
defaultValues: {
name: '',
description: '',
allocationType: 'auto' as 'auto' | 'explicit',
prefixLen: 24,
pool: '',
subnet: '',
},
})

const allocationType = form.watch('allocationType')

return (
<SideModalForm
form={form}
formType="create"
resourceName="external subnet"
onDismiss={() => navigate(pb.externalSubnets(projectSelector))}
onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => {
const body: ExternalSubnetCreate =
allocationType === 'explicit'
? { name, description, allocator: { type: 'explicit', subnet } }
: {
name,
description,
allocator: {
type: 'auto',
prefixLen,
poolSelector: pool ? { type: 'explicit', pool } : undefined,
},
}
createExternalSubnet.mutate({ query: projectSelector, body })
}}
loading={createExternalSubnet.isPending}
submitError={createExternalSubnet.error}
>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<RadioField
name="allocationType"
label="Allocation method"
control={form.control}
items={[
{ value: 'auto', label: 'Auto' },
{ value: 'explicit', label: 'Explicit' },
]}
/>
{allocationType === 'auto' ? (
<>
<NumberField
name="prefixLen"
label="Prefix length"
required
control={form.control}
min={8}
max={32}
description="The prefix length for the allocated subnet (e.g., 24 for a /24). Minimum 8."
/>
{/* Subnet pool list endpoint not yet available
https://github.com/oxidecomputer/omicron/issues/9814 */}
<ListboxField
name="pool"
label="Subnet pool"
control={form.control}
placeholder="Default"
noItemsPlaceholder="No pools linked to silo"
items={[]}
description="Subnet pool to allocate from. If not selected, the silo default is used."
/>
</>
) : (
<TextField
name="subnet"
label="Subnet CIDR"
required
control={form.control}
description="The subnet to reserve, e.g., 10.128.1.0/24"
/>
)}
</SideModalForm>
)
}
114 changes: 114 additions & 0 deletions app/forms/external-subnet-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'

import {
api,
getListQFn,
q,
queryClient,
useApiMutation,
usePrefetchedQuery,
} from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { titleCrumb } from '~/hooks/use-crumbs'
import { getExternalSubnetSelector, useExternalSubnetSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { EmptyCell } from '~/table/cells/EmptyCell'
import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { ALL_ISH } from '~/util/consts'
import { pb } from '~/util/path-builder'

const externalSubnetView = ({
project,
externalSubnet,
}: {
project: string
externalSubnet: string
}) =>
q(api.externalSubnetView, {
path: { externalSubnet },
query: { project },
})

const instanceList = (project: string) =>
getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getExternalSubnetSelector(params)
await Promise.all([
queryClient.fetchQuery(externalSubnetView(selector)),
queryClient.fetchQuery(instanceList(selector.project).optionsFn()),
])
return null
}

export const handle = titleCrumb('Edit External Subnet')

export default function EditExternalSubnetSideModalForm() {
const navigate = useNavigate()

const subnetSelector = useExternalSubnetSelector()
const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project }))

const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector))
const { data: instances } = usePrefetchedQuery(
instanceList(subnetSelector.project).optionsFn()
)
const instanceName = instances.items.find((i) => i.id === subnet.instanceId)?.name

const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, {
onSuccess(updated) {
queryClient.invalidateEndpoint('externalSubnetList')
// prettier-ignore
addToast(<>External subnet <HL>{updated.name}</HL> updated</>)
onDismiss()
},
})

const form = useForm({ defaultValues: subnet })
return (
<SideModalForm
form={form}
formType="edit"
resourceName="external subnet"
onDismiss={onDismiss}
onSubmit={({ name, description }) => {
editExternalSubnet.mutate({
path: { externalSubnet: subnetSelector.externalSubnet },
query: { project: subnetSelector.project },
body: { name, description },
})
}}
loading={editExternalSubnet.isPending}
submitError={editExternalSubnet.error}
>
<PropertiesTable>
<PropertiesTable.IdRow id={subnet.id} />
<PropertiesTable.DateRow label="Created" date={subnet.timeCreated} />
<PropertiesTable.DateRow label="Updated" date={subnet.timeModified} />
<PropertiesTable.Row label="Subnet">{subnet.subnet}</PropertiesTable.Row>
<PropertiesTable.Row label="Instance">
{instanceName ? (
<InstanceLinkCell instanceId={subnet.instanceId} />
) : (
<EmptyCell />
)}
</PropertiesTable.Row>
</PropertiesTable>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
</SideModalForm>
)
}
2 changes: 1 addition & 1 deletion app/forms/firewall-rules-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ const ProtocolFilters = ({ control }: { control: Control<FirewallRuleValues> })
control={protocolForm.control}
description={
<>
Enter a code (0) or range (e.g. 1&ndash;3). Leave blank for all
Enter a code (0) or range (e.g., 1&ndash;3). Leave blank for all
traffic of type {selectedIcmpType}.
</>
}
Expand Down
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const requireParams =
}

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

export const useExternalSubnetSelector = () => useSelectedParams(getExternalSubnetSelector)
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)
Expand Down
5 changes: 5 additions & 0 deletions app/layouts/ProjectLayoutBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Networking16Icon,
Snapshots16Icon,
Storage16Icon,
Subnet16Icon,
} from '@oxide/design-system/icons/react'

import { TopBar } from '~/components/TopBar'
Expand Down Expand Up @@ -68,6 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
{ value: 'Snapshots', path: pb.snapshots(projectSelector) },
{ value: 'Images', path: pb.projectImages(projectSelector) },
{ value: 'VPCs', path: pb.vpcs(projectSelector) },
{ value: 'External Subnets', path: pb.externalSubnets(projectSelector) },
{ value: 'Floating IPs', path: pb.floatingIps(projectSelector) },
{ value: 'Affinity Groups', path: pb.affinity(projectSelector) },
{ value: 'Project Access', path: pb.projectAccess(projectSelector) },
Expand Down Expand Up @@ -111,6 +113,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) {
<NavLinkItem to={pb.vpcs(projectSelector)}>
<Networking16Icon /> VPCs
</NavLinkItem>
<NavLinkItem to={pb.externalSubnets(projectSelector)}>
<Subnet16Icon /> External Subnets
</NavLinkItem>
<NavLinkItem to={pb.floatingIps(projectSelector)}>
<IpGlobal16Icon /> Floating IPs
</NavLinkItem>
Expand Down
Loading
Loading