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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
- 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.
- MSW starts fresh with a new db on every page load, so in E2E tests, use client-side navigation (click links/breadcrumbs) after mutations instead of `page.goto` to preserve db state within a test.

# Routing

Expand Down
3 changes: 2 additions & 1 deletion app/forms/ip-pool-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { useForm } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
import * as R from 'remeda'

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

Expand Down Expand Up @@ -40,7 +41,7 @@ export default function EditIpPoolSideModalForm() {

const { data: pool } = usePrefetchedQuery(ipPoolView(poolSelector))

const form = useForm({ defaultValues: pool })
const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })

const editPool = useApiMutation(api.systemIpPoolUpdate, {
onSuccess(updatedPool) {
Expand Down
80 changes: 80 additions & 0 deletions app/forms/subnet-pool-create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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 SubnetPoolCreate } from '@oxide/api'

import { DescriptionField } from '~/components/form/fields/DescriptionField'
import { NameField } from '~/components/form/fields/NameField'
import { RadioField } from '~/components/form/fields/RadioField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { HL } from '~/components/HL'
import { titleCrumb } from '~/hooks/use-crumbs'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

const defaultValues: SubnetPoolCreate = {
name: '',
description: '',
ipVersion: 'v4',
}

export const handle = titleCrumb('New subnet pool')

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

const onDismiss = () => navigate(pb.subnetPools())

const createPool = useApiMutation(api.systemSubnetPoolCreate, {
onSuccess(_pool) {
queryClient.invalidateEndpoint('systemSubnetPoolList')
// prettier-ignore
addToast(<>Subnet pool <HL>{_pool.name}</HL> created</>)
navigate(pb.subnetPools())
},
})

const form = useForm<SubnetPoolCreate>({ defaultValues })

return (
<SideModalForm
form={form}
formType="create"
resourceName="subnet pool"
onDismiss={onDismiss}
onSubmit={({ name, description, ipVersion }) => {
createPool.mutate({ body: { name, description, ipVersion } })
}}
loading={createPool.isPending}
submitError={createPool.error}
>
<Message
variant="info"
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
/>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<RadioField
name="ipVersion"
label="IP version"
column
control={form.control}
items={[
{ value: 'v4', label: 'v4' },
{ value: 'v6', label: 'v6' },
]}
/>
<SideModalFormDocs docs={[docLinks.subnetPools]} />
</SideModalForm>
)
}
83 changes: 83 additions & 0 deletions app/forms/subnet-pool-edit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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 * as R from 'remeda'

import { api, 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 { makeCrumb } from '~/hooks/use-crumbs'
import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'
import type * as PP from '~/util/path-params'

const subnetPoolView = ({ subnetPool }: PP.SubnetPool) =>
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })

export async function clientLoader({ params }: LoaderFunctionArgs) {
const selector = getSubnetPoolSelector(params)
await queryClient.prefetchQuery(subnetPoolView(selector))
return null
}

export const handle = makeCrumb('Edit subnet pool')

export default function EditSubnetPoolSideModalForm() {
const navigate = useNavigate()
const poolSelector = useSubnetPoolSelector()

const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector))

const form = useForm({ defaultValues: R.pick(pool, ['name', 'description']) })

const editPool = useApiMutation(api.systemSubnetPoolUpdate, {
onSuccess(updatedPool) {
queryClient.invalidateEndpoint('systemSubnetPoolList')
navigate(pb.subnetPool({ subnetPool: updatedPool.name }))
// prettier-ignore
addToast(<>Subnet pool <HL>{updatedPool.name}</HL> updated</>)

if (pool.name === updatedPool.name) {
queryClient.invalidateEndpoint('systemSubnetPoolView')
}
},
})

return (
<SideModalForm
form={form}
formType="edit"
resourceName="subnet pool"
onDismiss={() => navigate(pb.subnetPool({ subnetPool: poolSelector.subnetPool }))}
onSubmit={({ name, description }) => {
editPool.mutate({
path: { pool: poolSelector.subnetPool },
body: { name, description },
})
}}
loading={editPool.isPending}
submitError={editPool.error}
>
<Message
variant="info"
content="Users in linked silos will use subnet pool names and descriptions to help them choose a pool when allocating external subnets."
/>
<NameField name="name" control={form.control} />
<DescriptionField name="description" control={form.control} />
<SideModalFormDocs docs={[docLinks.subnetPools]} />
</SideModalForm>
)
}
112 changes: 112 additions & 0 deletions app/forms/subnet-pool-member-add.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/*
* 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, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'

import { NumberField } from '~/components/form/fields/NumberField'
import { TextField } from '~/components/form/fields/TextField'
import { SideModalForm } from '~/components/form/SideModalForm'
import { titleCrumb } from '~/hooks/use-crumbs'
import { useSubnetPoolSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Message } from '~/ui/lib/Message'
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
import { docLinks } from '~/util/links'
import { pb } from '~/util/path-builder'

type MemberAddForm = {
subnet: string
minPrefixLength: number
maxPrefixLength: number
}

const defaultValues: MemberAddForm = {
subnet: '',
minPrefixLength: NaN,
maxPrefixLength: NaN,
}

export const handle = titleCrumb('Add Member')

export default function SubnetPoolMemberAdd() {
const { subnetPool } = useSubnetPoolSelector()
const navigate = useNavigate()

const { data: poolData } = usePrefetchedQuery(
q(api.systemSubnetPoolView, { path: { pool: subnetPool } })
)

const onDismiss = () => navigate(pb.subnetPool({ subnetPool }))

const addMember = useApiMutation(api.systemSubnetPoolMemberAdd, {
onSuccess() {
queryClient.invalidateEndpoint('systemSubnetPoolMemberList')
addToast({ content: 'Member added' })
onDismiss()
},
})

const form = useForm<MemberAddForm>({ defaultValues })

const maxBound = poolData.ipVersion === 'v4' ? 32 : 128

return (
<SideModalForm
form={form}
formType="create"
resourceName="member"
title="Add member"
onDismiss={onDismiss}
onSubmit={({ subnet, minPrefixLength, maxPrefixLength }) => {
addMember.mutate({
path: { pool: subnetPool },
body: {
subnet,
minPrefixLength: Number.isNaN(minPrefixLength) ? undefined : minPrefixLength,
maxPrefixLength: Number.isNaN(maxPrefixLength) ? undefined : maxPrefixLength,
},
})
}}
loading={addMember.isPending}
submitError={addMember.error}
>
<Message
variant="info"
content={`This pool uses IP${poolData.ipVersion} addresses. Prefix lengths must be between 0 and ${maxBound}.`}
/>
{/* TODO: validate CIDR syntax, IP version match with pool, and
min/max prefix length relative to subnet prefix */}
<TextField
name="subnet"
label="Subnet"
description="CIDR notation (e.g., 10.0.0.0/16)"
control={form.control}
required
/>
<NumberField
name="minPrefixLength"
label="Min prefix length"
description={`Minimum prefix length for allocations (0–${maxBound})`}
control={form.control}
min={0}
max={maxBound}
/>
<NumberField
name="maxPrefixLength"
label="Max prefix length"
description={`Maximum prefix length for allocations (0–${maxBound})`}
control={form.control}
min={0}
max={maxBound}
/>
<SideModalFormDocs docs={[docLinks.subnetPools]} />
</SideModalForm>
)
}
2 changes: 2 additions & 0 deletions app/hooks/use-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const getProjectSnapshotSelector = requireParams('project', 'snapshot')
export const requireSledParams = requireParams('sledId')
export const requireUpdateParams = requireParams('version')
export const getIpPoolSelector = requireParams('pool')
export const getSubnetPoolSelector = requireParams('subnetPool')
export const getAffinityGroupSelector = requireParams('project', 'affinityGroup')
export const getAntiAffinityGroupSelector = requireParams('project', 'antiAffinityGroup')

Expand Down Expand Up @@ -102,6 +103,7 @@ export const useIdpSelector = () => useSelectedParams(getIdpSelector)
export const useSledParams = () => useSelectedParams(requireSledParams)
export const useUpdateParams = () => useSelectedParams(requireUpdateParams)
export const useIpPoolSelector = () => useSelectedParams(getIpPoolSelector)
export const useSubnetPoolSelector = () => useSelectedParams(getSubnetPoolSelector)
export const useAffinityGroupSelector = () => useSelectedParams(getAffinityGroupSelector)
export const useAntiAffinityGroupSelector = () =>
useSelectedParams(getAntiAffinityGroupSelector)
5 changes: 5 additions & 0 deletions app/layouts/SystemLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Metrics16Icon,
Servers16Icon,
SoftwareUpdate16Icon,
Subnet16Icon,
} from '@oxide/design-system/icons/react'

import { trigger404 } from '~/components/ErrorBoundary'
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function SystemLayout() {
{ value: 'Utilization', path: pb.systemUtilization() },
{ value: 'Inventory', path: pb.sledInventory() },
{ value: 'IP Pools', path: pb.ipPools() },
{ value: 'Subnet Pools', path: pb.subnetPools() },
{ value: 'System Update', path: pb.systemUpdate() },
{ value: 'Fleet Access', path: pb.fleetAccess() },
]
Expand Down Expand Up @@ -96,6 +98,9 @@ export default function SystemLayout() {
<NavLinkItem to={pb.ipPools()}>
<IpGlobal16Icon /> IP Pools
</NavLinkItem>
<NavLinkItem to={pb.subnetPools()}>
<Subnet16Icon /> Subnet Pools
</NavLinkItem>
<NavLinkItem to={pb.systemUpdate()}>
<SoftwareUpdate16Icon /> System Update
</NavLinkItem>
Expand Down
Loading
Loading