Skip to content
Merged
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
- Only implement what is necessary to exercise the UI; keep the db seeded via `mock-api/msw/db.ts`.
- Store API response objects in the mock tables when possible so state persists across calls.
- Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`).
- 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.

# Routing

Expand Down
2 changes: 2 additions & 0 deletions app/api/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ 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 SubnetPool = Readonly<{ subnetPool?: string }>
export type ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>

export type Id = Readonly<{ id: string }>
4 changes: 2 additions & 2 deletions app/components/AttachEphemeralIpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from '~/api'
import { ListboxField } from '~/components/form/fields/ListboxField'
import { HL } from '~/components/HL'
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
import { toPoolItem } from '~/components/PoolListboxItem'
import { useInstanceSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
Expand Down Expand Up @@ -90,7 +90,7 @@ export const AttachEphemeralIpModal = ({
name="pool"
label="Pool"
control={form.control}
items={sortPools(compatibleUnicastPools).map(toIpPoolItem)}
items={sortPools(compatibleUnicastPools).map(toPoolItem)}
disabled={compatibleUnicastPools.length === 0}
placeholder="Select a pool"
noItemsPlaceholder="No pools available"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@
* Copyright Oxide Computer Company
*/

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

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

/** Format a SiloIpPool for use as a ListboxField item */
export function toIpPoolItem(p: SiloIpPool): ListboxItem {
/** Common fields of SiloIpPool and SiloSubnetPool used for display */
type PoolLike = {
name: string
isDefault: boolean
ipVersion: IpVersion
description: string
}

/** Format a pool for use as a ListboxField item */
export function toPoolItem(p: PoolLike): ListboxItem {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We love TypeScript.

const value = p.name
const selectedLabel = p.name
const label = (
Expand Down
159 changes: 159 additions & 0 deletions app/forms/external-subnet-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* 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 { match } from 'ts-pattern'

import { api, q, queryClient, useApiMutation, usePrefetchedQuery } 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 { toPoolItem } from '~/components/PoolListboxItem'
import { titleCrumb } from '~/hooks/use-crumbs'
import { useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { ALL_ISH } from '~/util/consts'
import { validateIpNet } from '~/util/ip'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

const poolList = q(api.subnetPoolList, { query: { limit: ALL_ISH } })

export async function clientLoader() {
await queryClient.prefetchQuery(poolList)
return null
}

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

type FormValues = {
name: string
description: string
allocationType: 'auto' | 'explicit'
prefixLen: number
pool: string
subnet: string
}

const defaultFormValues: Omit<FormValues, 'pool'> = {
name: '',
description: '',
allocationType: 'auto',
prefixLen: 24,
subnet: '',
}

export default function CreateExternalSubnetSideModalForm() {
const { data: pools } = usePrefetchedQuery(poolList)

const defaultPool = pools.items.find((p) => p.isDefault)

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: { ...defaultFormValues, pool: defaultPool?.name ?? '' },
})

const allocationType = form.watch('allocationType')
const selectedPoolName = form.watch('pool')
const selectedPool = pools.items.find((p) => p.name === selectedPoolName)
// In auto allocation, the requested prefix length is matched against pool
// members whose min/max prefix range includes it, then a subnet is carved
// out of the first member with a large enough gap. Reproducing this member
// resolution logic is more or less impossible, so we just enforce a max by
// IP version.
// https://github.com/oxidecomputer/omicron/blob/e7d260a/nexus/db-queries/src/db/queries/external_subnet.rs#L906-L908
const prefixLenMax = !selectedPool || selectedPool.ipVersion === 'v6' ? 128 : 32

return (
<SideModalForm
form={form}
formType="create"
resourceName="external subnet"
onDismiss={() => navigate(pb.externalSubnets(projectSelector))}
onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => {
const allocator = match(allocationType)
.with('explicit', () => ({ type: 'explicit' as const, subnet }))
.with('auto', () => ({
type: 'auto' as const,
prefixLen,
poolSelector: { type: 'explicit' as const, pool },
}))
.exhaustive()
createExternalSubnet.mutate({
query: projectSelector,
body: { name, description, allocator },
})
}}
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' ? (
<>
<ListboxField
name="pool"
label="Subnet pool"
control={form.control}
placeholder="Select a pool"
noItemsPlaceholder="No pools linked to silo"
items={pools.items.map(toPoolItem)}
required
description="Subnet pool to allocate from"
/>
<NumberField
name="prefixLen"
label="Prefix length"
required
control={form.control}
min={1}
max={prefixLenMax}
description="Prefix length for the allocated subnet (e.g., 24 for a /24). Max is 32 for IPv4 pools, 128 for IPv6."
/>
</>
) : (
<TextField
name="subnet"
label="Subnet CIDR"
required
control={form.control}
validate={validateIpNet}
description="The subnet to reserve, e.g., 10.128.1.0/24"
/>
)}
<SideModalFormDocs docs={[docLinks.externalSubnets]} />
</SideModalForm>
)
}
120 changes: 120 additions & 0 deletions app/forms/external-subnet-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* 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,
q,
qErrorsAllowed,
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 { InstanceLink } from '~/table/cells/InstanceLinkCell'
import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

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

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getExternalSubnetSelector(params)
const subnet = await queryClient.fetchQuery(externalSubnetView(selector))
await Promise.all([
queryClient.prefetchQuery(
// subnet pool cell uses errors allowed, so we have to do that here to match
qErrorsAllowed(
api.subnetPoolView,
{ path: { pool: subnet.subnetPoolId } },
{
errorsExpected: {
explanation: 'the referenced subnet pool may have been deleted.',
statusCode: 404,
},
}
)
),
subnet.instanceId
? queryClient.prefetchQuery(
q(api.instanceView, { path: { instance: subnet.instanceId } })
)
: null,
])
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 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="Subnet Pool">
<SubnetPoolCell subnetPoolId={subnet.subnetPoolId} />
</PropertiesTable.Row>
<PropertiesTable.Row label="Instance">
<InstanceLink instanceId={subnet.instanceId} tab="networking" />
</PropertiesTable.Row>
</PropertiesTable>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<SideModalFormDocs docs={[docLinks.externalSubnets]} />
</SideModalForm>
)
}
4 changes: 2 additions & 2 deletions app/forms/floating-ip-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
import { NameField } from '~/components/form/fields/NameField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
import { toPoolItem } from '~/components/PoolListboxItem'
import { titleCrumb } from '~/hooks/use-crumbs'
import { useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
Expand Down Expand Up @@ -103,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() {
name="pool"
label="Pool"
control={form.control}
items={sortPools(unicastPools).map(toIpPoolItem)}
items={sortPools(unicastPools).map(toPoolItem)}
required
placeholder="Select a pool"
noItemsPlaceholder="No pools available"
Expand Down
4 changes: 2 additions & 2 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField'
import { Form } from '~/components/form/Form'
import { FullPageForm } from '~/components/form/FullPageForm'
import { HL } from '~/components/HL'
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
import { toPoolItem } from '~/components/PoolListboxItem'
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Button } from '~/ui/lib/Button'
Expand Down Expand Up @@ -335,7 +335,7 @@ function EphemeralIpCheckbox({
<ListboxField
name={poolFieldName}
control={control}
items={pools.map(toIpPoolItem)}
items={pools.map(toPoolItem)}
disabled={isSubmitting}
required={checked}
hideOptionalTag
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
Loading
Loading