From 24ba19767181eb1c6e01b094218d982fbe2c2775 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 19 Mar 2026 23:15:52 -0500 Subject: [PATCH] operator subnet pools UI --- AGENTS.md | 1 + app/forms/ip-pool-edit.tsx | 3 +- app/forms/subnet-pool-create.tsx | 80 +++ app/forms/subnet-pool-edit.tsx | 83 +++ app/forms/subnet-pool-member-add.spec.ts | 101 ++++ app/forms/subnet-pool-member-add.tsx | 181 +++++++ app/hooks/use-params.ts | 2 + app/layouts/SystemLayout.tsx | 5 + .../system/networking/SubnetPoolPage.tsx | 505 ++++++++++++++++++ .../system/networking/SubnetPoolsPage.tsx | 156 ++++++ app/pages/system/silos/SiloIpPoolsTab.tsx | 34 +- app/pages/system/silos/SiloPage.tsx | 1 + app/pages/system/silos/SiloSubnetPoolsTab.tsx | 333 ++++++++++++ app/routes.tsx | 28 + .../__snapshots__/path-builder.spec.ts.snap | 60 +++ app/util/links.ts | 4 + app/util/path-builder.spec.ts | 7 + app/util/path-builder.ts | 7 + app/util/path-params.ts | 1 + mock-api/msw/db.ts | 44 ++ mock-api/msw/handlers.ts | 257 ++++++++- mock-api/subnet-pool.ts | 75 ++- test/e2e/subnet-pools.e2e.ts | 289 ++++++++++ 23 files changed, 2219 insertions(+), 38 deletions(-) create mode 100644 app/forms/subnet-pool-create.tsx create mode 100644 app/forms/subnet-pool-edit.tsx create mode 100644 app/forms/subnet-pool-member-add.spec.ts create mode 100644 app/forms/subnet-pool-member-add.tsx create mode 100644 app/pages/system/networking/SubnetPoolPage.tsx create mode 100644 app/pages/system/networking/SubnetPoolsPage.tsx create mode 100644 app/pages/system/silos/SiloSubnetPoolsTab.tsx create mode 100644 test/e2e/subnet-pools.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 5b34b807a0..8c634c737f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index c46c46521e..15c6b7505e 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -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' @@ -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) { diff --git a/app/forms/subnet-pool-create.tsx b/app/forms/subnet-pool-create.tsx new file mode 100644 index 0000000000..ae54b76348 --- /dev/null +++ b/app/forms/subnet-pool-create.tsx @@ -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 {_pool.name} created) + navigate(pb.subnetPools()) + }, + }) + + const form = useForm({ defaultValues }) + + return ( + { + createPool.mutate({ body: { name, description, ipVersion } }) + }} + loading={createPool.isPending} + submitError={createPool.error} + > + + + + + + + ) +} diff --git a/app/forms/subnet-pool-edit.tsx b/app/forms/subnet-pool-edit.tsx new file mode 100644 index 0000000000..7a2868ce30 --- /dev/null +++ b/app/forms/subnet-pool-edit.tsx @@ -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 {updatedPool.name} updated) + + if (pool.name === updatedPool.name) { + queryClient.invalidateEndpoint('systemSubnetPoolView') + } + }, + }) + + return ( + navigate(pb.subnetPool({ subnetPool: poolSelector.subnetPool }))} + onSubmit={({ name, description }) => { + editPool.mutate({ + path: { pool: poolSelector.subnetPool }, + body: { name, description }, + }) + }} + loading={editPool.isPending} + submitError={editPool.error} + > + + + + + + ) +} diff --git a/app/forms/subnet-pool-member-add.spec.ts b/app/forms/subnet-pool-member-add.spec.ts new file mode 100644 index 0000000000..67e297e0a1 --- /dev/null +++ b/app/forms/subnet-pool-member-add.spec.ts @@ -0,0 +1,101 @@ +/* + * 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 { describe, expect, it } from 'vitest' + +import { createResolver } from './subnet-pool-member-add' + +const resolve = createResolver('v4') +const resolve6 = createResolver('v6') + +const valid = { subnet: '10.0.0.0/16', minPrefixLength: 20, maxPrefixLength: 28 } + +type Result = ReturnType + +function errMsg(result: Result, field: keyof Result['errors']) { + return result.errors[field]?.message +} + +describe('createResolver', () => { + it('accepts valid v4 input', () => { + expect(Object.keys(resolve(valid).errors)).toEqual([]) + }) + + it('accepts valid v6 input', () => { + const result = resolve6({ + subnet: 'fd00:1000::/32', + minPrefixLength: 48, + maxPrefixLength: 64, + }) + expect(Object.keys(result.errors)).toEqual([]) + }) + + it('accepts omitted prefix lengths', () => { + const result = resolve({ + subnet: '10.0.0.0/16', + minPrefixLength: NaN, + maxPrefixLength: NaN, + }) + expect(Object.keys(result.errors)).toEqual([]) + }) + + it('rejects invalid CIDR', () => { + const result = resolve({ ...valid, subnet: 'not-a-cidr' }) + expect(errMsg(result, 'subnet')).toMatch(/IP address/) + }) + + it('rejects v6 subnet in v4 pool', () => { + const result = resolve({ ...valid, subnet: 'fd00::/32' }) + expect(errMsg(result, 'subnet')).toBe('IPv6 subnet not allowed in IPv4 pool') + }) + + it('rejects v4 subnet in v6 pool', () => { + const result = resolve6({ ...valid, subnet: '10.0.0.0/16' }) + expect(errMsg(result, 'subnet')).toBe('IPv4 subnet not allowed in IPv6 pool') + }) + + it('rejects min > max prefix length', () => { + const result = resolve({ ...valid, minPrefixLength: 28, maxPrefixLength: 20 }) + expect(errMsg(result, 'minPrefixLength')).toMatch(/≤/) + }) + + it('rejects min prefix length < subnet width', () => { + const result = resolve({ ...valid, minPrefixLength: 8 }) + expect(errMsg(result, 'minPrefixLength')).toMatch(/≥ subnet prefix length \(16\)/) + }) + + it('rejects max prefix length < subnet width', () => { + const result = resolve({ ...valid, maxPrefixLength: 8 }) + expect(errMsg(result, 'maxPrefixLength')).toMatch(/≥ subnet prefix length \(16\)/) + }) + + it('rejects prefix length above max bound (v4: 32)', () => { + const result = resolve({ ...valid, minPrefixLength: 33 }) + expect(errMsg(result, 'minPrefixLength')).toBe('Must be between 0 and 32') + }) + + it('rejects prefix length below 0', () => { + const result = resolve({ ...valid, maxPrefixLength: -1 }) + expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 32') + }) + + it('shows min-≤-max error even when min is also below subnet width', () => { + // min(12) > max(10) AND min(12) < subnetWidth(16): the min-≤-max error + // should take priority over the subnet-width error + const result = resolve({ ...valid, minPrefixLength: 12, maxPrefixLength: 10 }) + expect(errMsg(result, 'minPrefixLength')).toMatch(/≤/) + }) + + it('rejects prefix length above max bound (v6: 128)', () => { + const result = resolve6({ + subnet: 'fd00::/32', + minPrefixLength: 48, + maxPrefixLength: 200, + }) + expect(errMsg(result, 'maxPrefixLength')).toBe('Must be between 0 and 128') + }) +}) diff --git a/app/forms/subnet-pool-member-add.tsx b/app/forms/subnet-pool-member-add.tsx new file mode 100644 index 0000000000..7e781b0855 --- /dev/null +++ b/app/forms/subnet-pool-member-add.tsx @@ -0,0 +1,181 @@ +/* + * 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 { useCallback } from 'react' +import { useForm, type FieldErrors } from 'react-hook-form' +import { useNavigate } from 'react-router' + +import { + api, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type IpVersion, +} 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 { parseIpNet } from '~/util/ip' +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 function createResolver(poolVersion: IpVersion) { + return (values: MemberAddForm) => { + const errors: FieldErrors = {} + const maxBound = poolVersion === 'v4' ? 32 : 128 + + const parsed = parseIpNet(values.subnet) + if (parsed.type === 'error') { + errors.subnet = { type: 'pattern', message: parsed.message } + } else if (parsed.type !== poolVersion) { + errors.subnet = { + type: 'pattern', + message: `IP${parsed.type} subnet not allowed in IP${poolVersion} pool`, + } + } + + const { minPrefixLength: minPL, maxPrefixLength: maxPL } = values + const subnetWidth = parsed.type !== 'error' ? parsed.width : undefined + const inRange = (v: number) => !Number.isNaN(v) && v >= 0 && v <= maxBound + + // min and max prefix length are optional, and NaN is the value they have + // when they're unset (matching NumberField) + + // min prefix: bounds → ordering → subnet width + if (!Number.isNaN(minPL) && !inRange(minPL)) { + errors.minPrefixLength = { + type: 'validate', + message: `Must be between 0 and ${maxBound}`, + } + } else if (inRange(minPL) && inRange(maxPL) && minPL > maxPL) { + errors.minPrefixLength = { + type: 'validate', + message: 'Min prefix length must be ≤ max prefix length', + } + } else if (inRange(minPL) && subnetWidth !== undefined && minPL < subnetWidth) { + errors.minPrefixLength = { + type: 'validate', + message: `Must be ≥ subnet prefix length (${subnetWidth})`, + } + } + + // max prefix: bounds → subnet width + if (!Number.isNaN(maxPL) && !inRange(maxPL)) { + errors.maxPrefixLength = { + type: 'validate', + message: `Must be between 0 and ${maxBound}`, + } + } else if (inRange(maxPL) && subnetWidth !== undefined && maxPL < subnetWidth) { + errors.maxPrefixLength = { + type: 'validate', + message: `Must be ≥ subnet prefix length (${subnetWidth})`, + } + } + + return { values: Object.keys(errors).length > 0 ? {} : values, errors } + } +} + +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 resolver = useCallback( + (values: MemberAddForm) => createResolver(poolData.ipVersion)(values), + [poolData.ipVersion] + ) + + const form = useForm({ defaultValues, resolver }) + + const maxBound = poolData.ipVersion === 'v4' ? 32 : 128 + + return ( + { + 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} + > + + + + + + + ) +} diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index d00f482ede..5298181d96 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -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') @@ -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) diff --git a/app/layouts/SystemLayout.tsx b/app/layouts/SystemLayout.tsx index 2c66b7ac14..fca0d33b88 100644 --- a/app/layouts/SystemLayout.tsx +++ b/app/layouts/SystemLayout.tsx @@ -15,6 +15,7 @@ import { Metrics16Icon, Servers16Icon, SoftwareUpdate16Icon, + Subnet16Icon, } from '@oxide/design-system/icons/react' import { trigger404 } from '~/components/ErrorBoundary' @@ -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() }, ] @@ -96,6 +98,9 @@ export default function SystemLayout() { IP Pools + + Subnet Pools + System Update diff --git a/app/pages/system/networking/SubnetPoolPage.tsx b/app/pages/system/networking/SubnetPoolPage.tsx new file mode 100644 index 0000000000..f4d2e88aa9 --- /dev/null +++ b/app/pages/system/networking/SubnetPoolPage.tsx @@ -0,0 +1,505 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type Silo, + type SubnetPoolMember, + type SubnetPoolSiloLink, +} from '@oxide/api' +import { Subnet16Icon, Subnet24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { DocsPopover } from '~/components/DocsPopover' +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' +import { MoreActionsMenu } from '~/components/MoreActionsMenu' +import { QueryParamTabs } from '~/components/QueryParamTabs' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getSubnetPoolSelector, useSubnetPoolSelector } from '~/hooks/use-params' +import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { SkeletonCell } from '~/table/cells/EmptyCell' +import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { toComboboxItems } from '~/ui/lib/Combobox' +import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' +import * as Dropdown from '~/ui/lib/DropdownMenu' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { Tabs } from '~/ui/lib/Tabs' +import { TipIcon } from '~/ui/lib/TipIcon' +import { ALL_ISH } from '~/util/consts' +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 } }) +const subnetPoolSiloList = ({ subnetPool }: PP.SubnetPool) => + getListQFn(api.systemSubnetPoolSiloList, { path: { pool: subnetPool } }) +const subnetPoolMemberList = ({ subnetPool }: PP.SubnetPool) => + getListQFn(api.systemSubnetPoolMemberList, { path: { pool: subnetPool } }) +const siloList = q(api.siloList, { query: { limit: ALL_ISH } }) +const siloView = ({ silo }: PP.Silo) => q(api.siloView, { path: { silo } }) +const siloSubnetPoolList = (silo: string) => + q(api.siloSubnetPoolList, { path: { silo }, query: { limit: ALL_ISH } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getSubnetPoolSelector(params) + await Promise.all([ + queryClient.prefetchQuery(subnetPoolView(selector)), + queryClient.fetchQuery(subnetPoolSiloList(selector).optionsFn()).then((links) => { + for (const link of links.items.slice(0, 50)) { + queryClient.prefetchQuery(siloSubnetPoolList(link.siloId)) + } + }), + queryClient.prefetchQuery(subnetPoolMemberList(selector).optionsFn()), + queryClient.fetchQuery(siloList).then((silos) => { + for (const silo of silos.items) { + queryClient.setQueryData(siloView({ silo: silo.id }).queryKey, silo) + } + }), + ]) + return null +} + +export const handle = makeCrumb((p) => p.subnetPool!) + +export default function SubnetPoolPage() { + const poolSelector = useSubnetPoolSelector() + const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector)) + const { data: members } = usePrefetchedQuery( + subnetPoolMemberList(poolSelector).optionsFn() + ) + const navigate = useNavigate() + const { mutateAsync: deletePool } = useApiMutation(api.systemSubnetPoolDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('systemSubnetPoolList') + navigate(pb.subnetPools()) + // prettier-ignore + addToast(<>Subnet pool {variables.path.pool} deleted) + }, + }) + + return ( + <> + + }>{pool.name} +
+ } + summary="Subnet pools are collections of IP subnets you can assign to silos. When a pool is linked to a silo, users in that silo can allocate external subnets from the pool." + links={[docLinks.subnetPools]} + /> + + Edit + deletePool({ path: { pool: pool.name } }), + label: pool.name, + })} + disabled={ + !!members.items.length && + 'Subnet pool cannot be deleted while it contains members' + } + className={members.items.length ? '' : 'destructive'} + /> + +
+
+ + + + Members + Linked silos + + + + + + + + + + + ) +} + +function PoolProperties() { + const poolSelector = useSubnetPoolSelector() + const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector)) + + return ( + + + + + + + {/* TODO: add utilization row once Nexus endpoint is implemented + https://github.com/oxidecomputer/omicron/issues/10109 */} + + + ) +} + +const membersColHelper = createColumnHelper() +const membersStaticCols = [ + membersColHelper.accessor('subnet', { header: 'Subnet' }), + membersColHelper.accessor('minPrefixLength', { header: 'Min prefix length' }), + membersColHelper.accessor('maxPrefixLength', { header: 'Max prefix length' }), + membersColHelper.accessor('timeCreated', Columns.timeCreated), +] + +function MembersTable() { + const { subnetPool } = useSubnetPoolSelector() + + const { mutateAsync: removeMember } = useApiMutation(api.systemSubnetPoolMemberRemove, { + onSuccess() { + queryClient.invalidateEndpoint('systemSubnetPoolMemberList') + }, + }) + const emptyState = ( + } + title="No members" + body="Add a member to see it here" + buttonText="Add member" + buttonTo={pb.subnetPoolMemberAdd({ subnetPool })} + /> + ) + + const makeMemberActions = useCallback( + (member: SubnetPoolMember): MenuAction[] => [ + { + label: 'Remove', + className: 'destructive', + onActivate: () => + confirmAction({ + doAction: () => + removeMember({ + path: { pool: subnetPool }, + body: { subnet: member.subnet }, + }), + errorTitle: 'Could not remove member', + modalTitle: 'Confirm remove member', + modalContent: ( +

+ Are you sure you want to remove subnet {member.subnet} from the + pool? This will fail if the subnet has any addresses in use. +

+ ), + actionType: 'danger', + }), + }, + ], + [subnetPool, removeMember] + ) + const columns = useColsWithActions(membersStaticCols, makeMemberActions) + const { table } = useQueryTable({ + query: subnetPoolMemberList({ subnetPool }), + columns, + emptyState, + }) + + return ( + <> +
+ Add member +
+ {table} + + ) +} + +function SiloNameFromId({ value: siloId }: { value: string }) { + const { data: silo } = useQuery(q(api.siloView, { path: { silo: siloId } })) + + if (!silo) return + + return {silo.name} +} + +const silosColHelper = createColumnHelper() + +function getSiloLabel(siloId: string) { + const siloName = queryClient.getQueryData(siloView({ silo: siloId }).queryKey)?.name + // prettier-ignore + return siloName + ? <>silo {siloName} + : 'that silo' +} + +function LinkedSilosTable() { + const poolSelector = useSubnetPoolSelector() + const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector)) + + const { mutateAsync: unlinkSilo } = useApiMutation(api.systemSubnetPoolSiloUnlink, { + onSuccess() { + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + }, + }) + const { mutateAsync: updateSiloLink } = useApiMutation(api.systemSubnetPoolSiloUpdate, { + onSuccess() { + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + queryClient.invalidateEndpoint('siloSubnetPoolList') + }, + }) + + const makeActions = useCallback( + (link: SubnetPoolSiloLink): MenuAction[] => [ + { + label: link.isDefault ? 'Clear default' : 'Make default', + className: link.isDefault ? 'destructive' : undefined, + onActivate() { + const siloLabel = getSiloLabel(link.siloId) + const versionLabel = `IP${pool.ipVersion}` + + if (link.isDefault) { + confirmAction({ + doAction: () => + updateSiloLink({ + path: { silo: link.siloId, pool: link.subnetPoolId }, + body: { isDefault: false }, + }), + modalTitle: 'Confirm clear default', + modalContent: ( +

+ Are you sure you want {pool.name} to stop being the default{' '} + {versionLabel} subnet pool for {siloLabel}? If there is no default, users + in this silo will have to specify a pool when allocating external subnets. +

+ ), + errorTitle: 'Could not clear default', + actionType: 'danger', + }) + } else { + void queryClient + .ensureQueryData(siloSubnetPoolList(link.siloId)) + .catch(() => null) + .then((siloPools) => { + const existingDefaultName = siloPools?.items.find( + (p) => p.isDefault && p.ipVersion === pool.ipVersion + )?.name + + const modalContent = existingDefaultName ? ( +

+ Are you sure you want to change the default {versionLabel} subnet pool + for {siloLabel} from {existingDefaultName} to{' '} + {pool.name}? +

+ ) : ( +

+ Are you sure you want to make {pool.name} the default{' '} + {versionLabel} subnet pool for {siloLabel}? +

+ ) + + const verb = existingDefaultName ? 'change' : 'make' + confirmAction({ + doAction: () => + updateSiloLink({ + path: { silo: link.siloId, pool: link.subnetPoolId }, + body: { isDefault: true }, + }), + modalTitle: `Confirm ${verb} default`, + modalContent, + errorTitle: `Could not ${verb} default`, + actionType: 'primary', + }) + }) + .catch(() => null) + } + }, + }, + { + label: 'Unlink', + className: 'destructive', + onActivate() { + const siloLabel = getSiloLabel(link.siloId) + confirmAction({ + doAction: () => + unlinkSilo({ path: { silo: link.siloId, pool: link.subnetPoolId } }), + modalTitle: 'Confirm unlink silo', + modalContent: ( +

+ Are you sure you want to unlink {siloLabel} from {pool.name}? Users + in the silo will no longer be able to allocate external subnets from this + pool. Unlink will fail if there are any subnets from the pool in use in the + silo. +

+ ), + errorTitle: 'Could not unlink silo', + actionType: 'danger', + }) + }, + }, + ], + [pool, unlinkSilo, updateSiloLink] + ) + + const [showLinkModal, setShowLinkModal] = useState(false) + + const emptyState = ( + } + title="No linked silos" + body="You can link this pool to a silo to see it here" + buttonText="Link silo" + onClick={() => setShowLinkModal(true)} + /> + ) + + const silosCols = useMemo( + () => [ + silosColHelper.accessor('siloId', { + header: 'Silo', + cell: (info) => , + }), + silosColHelper.accessor('isDefault', { + header: () => { + return ( + + Silo default + + When no pool is specified, subnets are allocated from the silo's default + subnet pool for the relevant version. + + + ) + }, + cell: (info) => (info.getValue() ? default : null), + }), + ], + [] + ) + + const columns = useColsWithActions(silosCols, makeActions) + const { table } = useQueryTable({ + query: subnetPoolSiloList(poolSelector), + columns, + emptyState, + getId: (link) => link.siloId, + }) + + return ( + <> +
+ setShowLinkModal(true)}>Link silo +
+ {table} + {showLinkModal && setShowLinkModal(false)} />} + + ) +} + +type LinkSiloFormValues = { + silo: string | undefined +} + +const defaultValues: LinkSiloFormValues = { silo: undefined } + +function LinkSiloModal({ onDismiss }: { onDismiss: () => void }) { + const { subnetPool } = useSubnetPoolSelector() + const { control, handleSubmit } = useForm({ defaultValues }) + + const linkSilo = useApiMutation(api.systemSubnetPoolSiloLink, { + onSuccess() { + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + onDismiss() + }, + onError(err) { + addToast({ title: 'Could not link silo', content: err.message, variant: 'error' }) + }, + }) + + function onSubmit({ silo }: LinkSiloFormValues) { + if (!silo) return + linkSilo.mutate({ path: { pool: subnetPool }, body: { silo, isDefault: false } }) + } + + const linkedSilos = useQuery( + q(api.systemSubnetPoolSiloList, { + path: { pool: subnetPool }, + query: { limit: ALL_ISH }, + }) + ) + const allSilos = useQuery(q(api.siloList, { query: { limit: ALL_ISH } })) + + const linkedSiloIds = useMemo( + () => + linkedSilos.data ? new Set(linkedSilos.data.items.map((s) => s.siloId)) : undefined, + [linkedSilos] + ) + const unlinkedSiloItems = useMemo( + () => + allSilos.data && linkedSiloIds + ? toComboboxItems(allSilos.data.items.filter((s) => !linkedSiloIds.has(s.id))) + : [], + [allSilos, linkedSiloIds] + ) + + return ( + + + +
{ + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} + className="space-y-4" + > + + + + +
+
+ +
+ ) +} diff --git a/app/pages/system/networking/SubnetPoolsPage.tsx b/app/pages/system/networking/SubnetPoolsPage.tsx new file mode 100644 index 0000000000..5a299167b4 --- /dev/null +++ b/app/pages/system/networking/SubnetPoolsPage.tsx @@ -0,0 +1,156 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback } from 'react' +import { Outlet, useNavigate } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + type SubnetPool, +} from '@oxide/api' +import { Subnet16Icon, Subnet24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { makeLinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const EmptyState = () => ( + } + title="No subnet pools" + body="Create a subnet pool to see it here" + buttonText="New subnet pool" + buttonTo={pb.subnetPoolsNew()} + /> +) + +const colHelper = createColumnHelper() + +// TODO: add utilization column once Nexus endpoint is implemented +// https://github.com/oxidecomputer/omicron/issues/10109 +const staticColumns = [ + colHelper.accessor('name', { + cell: makeLinkCell((pool) => pb.subnetPool({ subnetPool: pool })), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => , + }), + colHelper.accessor('timeCreated', Columns.timeCreated), +] + +const subnetPoolList = getListQFn(api.systemSubnetPoolList, {}) + +export async function clientLoader() { + await queryClient.prefetchQuery(subnetPoolList.optionsFn()) + return null +} + +export const handle = { crumb: 'Subnet Pools' } + +export default function SubnetPoolsPage() { + const navigate = useNavigate() + + const { mutateAsync: deletePool } = useApiMutation(api.systemSubnetPoolDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('systemSubnetPoolList') + // prettier-ignore + addToast(<>Subnet pool {variables.path.pool} deleted) + }, + }) + + const makeActions = useCallback( + (pool: SubnetPool): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => { + const poolView = q(api.systemSubnetPoolView, { + path: { pool: pool.name }, + }) + queryClient.setQueryData(poolView.queryKey, pool) + navigate(pb.subnetPoolEdit({ subnetPool: pool.name })) + }, + }, + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => deletePool({ path: { pool: pool.name } }), + label: pool.name, + }), + }, + ], + [deletePool, navigate] + ) + + const columns = useColsWithActions(staticColumns, makeActions) + const { table } = useQueryTable({ + query: subnetPoolList, + columns, + emptyState: , + }) + + const { data: allPools } = useQuery( + q(api.systemSubnetPoolList, { query: { limit: ALL_ISH } }) + ) + + useQuickActions( + () => [ + { + value: 'New subnet pool', + navGroup: 'Actions', + action: pb.subnetPoolsNew(), + }, + ...(allPools?.items || []).map((p) => ({ + value: p.name, + action: pb.subnetPool({ subnetPool: p.name }), + navGroup: 'Go to subnet pool', + })), + ], + [allPools] + ) + + return ( + <> + + }>Subnet Pools + } + summary="Subnet pools are collections of IP subnets you can assign to silos. When a pool is linked to a silo, users in that silo can allocate external subnets from the pool." + links={[docLinks.subnetPools]} + /> + + + New Subnet Pool + + {table} + + + ) +} diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index af0161aa87..df6bccb7dd 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -17,6 +17,7 @@ import { getListQFn, queryClient, useApiMutation, + usePrefetchedQuery, type IpPool, type SiloIpPool, } from '@oxide/api' @@ -57,15 +58,23 @@ function toIpPoolComboboxItem(p: IpPool): ComboboxItem { } } -const EmptyState = () => ( - } - title="No IP pools" - body="Create an IP pool to see it here" - buttonText="New IP pool" - buttonTo={pb.ipPoolsNew()} - /> -) +function EmptyState({ onLinkPool }: { onLinkPool: () => void }) { + const { data: allPools } = usePrefetchedQuery(allPoolsQuery.optionsFn()) + const emptyProps = allPools.items.length + ? { + body: 'Link an IP pool to this silo to see it here', + buttonText: 'Link pool', + onClick: onLinkPool, + } + : { + body: 'Create an IP pool to see it here', + buttonText: 'New IP pool', + buttonTo: pb.ipPoolsNew(), + } + return ( + } title="No linked IP pools" {...emptyProps} /> + ) +} const colHelper = createColumnHelper() @@ -80,7 +89,10 @@ export const siloIpPoolsQuery = (silo: string) => export async function clientLoader({ params }: LoaderFunctionArgs) { const { silo } = getSiloSelector(params) - await queryClient.prefetchQuery(siloIpPoolsQuery(silo).optionsFn()) + await Promise.all([ + queryClient.prefetchQuery(siloIpPoolsQuery(silo).optionsFn()), + queryClient.prefetchQuery(allPoolsQuery.optionsFn()), + ]) return null } @@ -232,7 +244,7 @@ export default function SiloIpPoolsTab() { const { table } = useQueryTable({ query: siloIpPoolsQuery(silo), columns, - emptyState: , + emptyState: setShowLinkModal(true)} />, }) return ( diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index af6875dca5..26ad9ee9ae 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -62,6 +62,7 @@ export default function SiloPage() { Identity Providers IP Pools + Subnet Pools Quotas Fleet roles SCIM diff --git a/app/pages/system/silos/SiloSubnetPoolsTab.tsx b/app/pages/system/silos/SiloSubnetPoolsTab.tsx new file mode 100644 index 0000000000..a59601c62d --- /dev/null +++ b/app/pages/system/silos/SiloSubnetPoolsTab.tsx @@ -0,0 +1,333 @@ +/* + * 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 { useQuery } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' +import { type LoaderFunctionArgs } from 'react-router' + +import { + api, + getListQFn, + queryClient, + useApiMutation, + usePrefetchedQuery, + type SiloSubnetPool, + type SubnetPool, +} from '@oxide/api' +import { Networking24Icon } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +import { ComboboxField } from '~/components/form/fields/ComboboxField' +import { HL } from '~/components/HL' +import { IpVersionBadge } from '~/components/IpVersionBadge' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' +import { confirmAction } from '~/stores/confirm-action' +import { addToast } from '~/stores/toast' +import { LinkCell } from '~/table/cells/LinkCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import type { ComboboxItem } from '~/ui/lib/Combobox' +import { CreateButton } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' +import { Tooltip } from '~/ui/lib/Tooltip' +import { ALL_ISH } from '~/util/consts' +import { pb } from '~/util/path-builder' + +function toSubnetPoolComboboxItem(p: SubnetPool): ComboboxItem { + return { + value: p.name, + selectedLabel: p.name, + label: ( +
+ {p.name} + +
+ ), + } +} + +function EmptyState({ onLinkPool }: { onLinkPool: () => void }) { + const { data: allPools } = usePrefetchedQuery(allPoolsQuery.optionsFn()) + const emptyProps = allPools.items.length + ? { + body: 'Link a subnet pool to this silo to see it here', + buttonText: 'Link pool', + onClick: onLinkPool, + } + : { + body: 'Create a subnet pool to see it here', + buttonText: 'New subnet pool', + buttonTo: pb.subnetPoolsNew(), + } + return ( + } + title="No linked subnet pools" + {...emptyProps} + /> + ) +} + +const colHelper = createColumnHelper() + +const allPoolsQuery = getListQFn(api.systemSubnetPoolList, { query: { limit: ALL_ISH } }) + +const allSiloPoolsQuery = (silo: string) => + getListQFn(api.siloSubnetPoolList, { path: { silo }, query: { limit: ALL_ISH } }) + +export const siloSubnetPoolsQuery = (silo: string) => + getListQFn(api.siloSubnetPoolList, { path: { silo } }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { silo } = getSiloSelector(params) + await Promise.all([ + queryClient.prefetchQuery(siloSubnetPoolsQuery(silo).optionsFn()), + queryClient.prefetchQuery(allPoolsQuery.optionsFn()), + ]) + return null +} + +export default function SiloSubnetPoolsTab() { + const { silo } = useSiloSelector() + const [showLinkModal, setShowLinkModal] = useState(false) + + const { data: allPoolsData } = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = allPoolsData?.items + + const staticCols = useMemo( + () => [ + colHelper.accessor('name', { + cell: (info) => ( + + {info.getValue()} + {info.row.original.isDefault && ( + + + default + + + )} + + ), + }), + colHelper.accessor('description', Columns.description), + colHelper.accessor('ipVersion', { + header: 'Version', + cell: (info) => , + }), + ], + [] + ) + + const findDefaultForVersion = useCallback( + (ipVersion: string) => + allPools?.find((p) => p.isDefault && p.ipVersion === ipVersion)?.name, + [allPools] + ) + + const { mutateAsync: updatePoolLink } = useApiMutation(api.systemSubnetPoolSiloUpdate, { + onSuccess() { + queryClient.invalidateEndpoint('siloSubnetPoolList') + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + }, + }) + const { mutateAsync: unlinkPool } = useApiMutation(api.systemSubnetPoolSiloUnlink, { + onSuccess() { + queryClient.invalidateEndpoint('siloSubnetPoolList') + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + addToast({ content: 'Subnet pool unlinked' }) + }, + }) + + const makeActions = useCallback( + (pool: SiloSubnetPool): MenuAction[] => [ + { + label: pool.isDefault ? 'Clear default' : 'Make default', + className: pool.isDefault ? 'destructive' : undefined, + onActivate() { + const versionLabel = `IP${pool.ipVersion}` + + if (pool.isDefault) { + confirmAction({ + doAction: () => + updatePoolLink({ + path: { silo, pool: pool.id }, + body: { isDefault: false }, + }), + modalTitle: 'Confirm clear default', + modalContent: ( +

+ Are you sure you want {pool.name} to stop being the default{' '} + {versionLabel} subnet pool for this silo? If there is no default, users in + this silo will have to specify a pool when allocating external subnets. +

+ ), + errorTitle: 'Could not clear default', + actionType: 'danger', + }) + } else { + const existingDefault = findDefaultForVersion(pool.ipVersion) + + const modalContent = existingDefault ? ( +

+ Are you sure you want to change the default {versionLabel} subnet pool from{' '} + {existingDefault} to {pool.name}? +

+ ) : ( +

+ Are you sure you want to make {pool.name} the default{' '} + {versionLabel} subnet pool for this silo? +

+ ) + const verb = existingDefault ? 'change' : 'make' + confirmAction({ + doAction: () => + updatePoolLink({ + path: { silo, pool: pool.id }, + body: { isDefault: true }, + }), + modalTitle: `Confirm ${verb} default`, + modalContent, + errorTitle: `Could not ${verb} default`, + actionType: 'primary', + }) + } + }, + }, + { + label: 'Unlink', + className: 'destructive', + onActivate() { + confirmAction({ + doAction: () => unlinkPool({ path: { silo, pool: pool.id } }), + modalTitle: 'Confirm unlink pool', + modalContent: ( +

+ Are you sure you want to unlink {pool.name}? Users in this silo + will no longer be able to allocate external subnets from this pool. Unlink + will fail if there are any subnets from {pool.name} in use in this + silo. +

+ ), + errorTitle: 'Could not unlink pool', + actionType: 'danger', + }) + }, + }, + ], + [findDefaultForVersion, silo, unlinkPool, updatePoolLink] + ) + + const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: siloSubnetPoolsQuery(silo), + columns, + emptyState: setShowLinkModal(true)} />, + }) + + return ( + <> +
+ setShowLinkModal(true)}>Link pool +
+ {table} + {showLinkModal && setShowLinkModal(false)} />} + + ) +} + +export const handle = makeCrumb('Subnet Pools') + +type LinkPoolFormValues = { + pool: string | undefined +} + +const defaultValues: LinkPoolFormValues = { pool: undefined } + +function LinkPoolModal({ onDismiss }: { onDismiss: () => void }) { + const { silo } = useSiloSelector() + const { control, handleSubmit } = useForm({ defaultValues }) + + const linkPool = useApiMutation(api.systemSubnetPoolSiloLink, { + onSuccess() { + queryClient.invalidateEndpoint('siloSubnetPoolList') + queryClient.invalidateEndpoint('systemSubnetPoolSiloList') + onDismiss() + }, + onError(err) { + addToast({ title: 'Could not link pool', content: err.message, variant: 'error' }) + }, + }) + + function onSubmit({ pool }: LinkPoolFormValues) { + if (!pool) return + linkPool.mutate({ path: { pool }, body: { silo, isDefault: false } }) + } + + const allLinkedPools = useQuery(allSiloPoolsQuery(silo).optionsFn()) + const allPools = useQuery(allPoolsQuery.optionsFn()) + + const linkedPoolIds = useMemo( + () => + allLinkedPools.data ? new Set(allLinkedPools.data.items.map((p) => p.id)) : undefined, + [allLinkedPools] + ) + const unlinkedPoolItems = useMemo( + () => + allPools.data && linkedPoolIds + ? allPools.data.items + .filter((p) => !linkedPoolIds.has(p.id)) + .map(toSubnetPoolComboboxItem) + : [], + [allPools, linkedPoolIds] + ) + + return ( + + + +
{ + e.stopPropagation() + handleSubmit(onSubmit)(e) + }} + className="space-y-4" + > + + + + +
+
+ +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 8795c29f55..5d07ce94bd 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -146,6 +146,10 @@ export const routes = createRoutesFromElements( path="ip-pools" lazy={() => import('./pages/system/silos/SiloIpPoolsTab').then(convert)} /> + import('./pages/system/silos/SiloSubnetPoolsTab').then(convert)} + /> import('./pages/system/silos/SiloQuotasTab').then(convert)} @@ -217,6 +221,15 @@ export const routes = createRoutesFromElements( lazy={() => import('./forms/ip-pool-create').then(convert)} /> + import('./pages/system/networking/SubnetPoolsPage').then(convert)} + > + + import('./forms/subnet-pool-create').then(convert)} + /> + + + import('./pages/system/networking/SubnetPoolPage').then(convert)} + > + import('./forms/subnet-pool-edit').then(convert)} + /> + import('./forms/subnet-pool-member-add').then(convert)} + /> + + import('./pages/system/UpdatePage').then(convert)} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index ad7d567938..0ac260b92d 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -719,6 +719,20 @@ exports[`breadcrumbs 2`] = ` "path": "/system/silos/s/scim", }, ], + "siloSubnetPools (/system/silos/s/subnet-pools)": [ + { + "label": "Silos", + "path": "/system/silos", + }, + { + "label": "s", + "path": "/system/silos/s/idps", + }, + { + "label": "Subnet Pools", + "path": "/system/silos/s/subnet-pools", + }, + ], "siloUtilization (/utilization)": [ { "label": "Utilization", @@ -837,6 +851,52 @@ exports[`breadcrumbs 2`] = ` "path": "/settings/ssh-keys", }, ], + "subnetPool (/system/networking/subnet-pools/sp)": [ + { + "label": "Subnet Pools", + "path": "/system/networking/subnet-pools", + }, + { + "label": "sp", + "path": "/system/networking/subnet-pools/sp", + }, + ], + "subnetPoolEdit (/system/networking/subnet-pools/sp/edit)": [ + { + "label": "Subnet Pools", + "path": "/system/networking/subnet-pools", + }, + { + "label": "sp", + "path": "/system/networking/subnet-pools/sp", + }, + { + "label": "Edit subnet pool", + "path": "/system/networking/subnet-pools/sp/edit", + }, + ], + "subnetPoolMemberAdd (/system/networking/subnet-pools/sp/members-add)": [ + { + "label": "Subnet Pools", + "path": "/system/networking/subnet-pools", + }, + { + "label": "sp", + "path": "/system/networking/subnet-pools/sp", + }, + ], + "subnetPools (/system/networking/subnet-pools)": [ + { + "label": "Subnet Pools", + "path": "/system/networking/", + }, + ], + "subnetPoolsNew (/system/networking/subnet-pools-new)": [ + { + "label": "Subnet Pools", + "path": "/system/networking/", + }, + ], "systemUpdate (/system/update)": [ { "label": "System Update", diff --git a/app/util/links.ts b/app/util/links.ts index b919ed509b..7c9fcfbf5a 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -148,6 +148,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/operator/ip-pool-management', linkText: 'IP Pools', }, + subnetPools: { + href: 'https://docs.oxide.computer/guides/operator/ip-pool-management#_using_subnet_pools', + linkText: 'Subnet Pools', + }, systemMetrics: { href: 'https://docs.oxide.computer/guides/operator/system-metrics', linkText: 'Metrics', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index c3538b66b6..ebd7d3e3d0 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -33,6 +33,7 @@ const params = { sshKey: 'ss', snapshot: 'sn', pool: 'pl', + subnetPool: 'sp', rule: 'fr', subnet: 'su', router: 'r', @@ -96,6 +97,7 @@ test('path builder', () => { "siloIpPools": "/system/silos/s/ip-pools", "siloQuotas": "/system/silos/s/quotas", "siloScim": "/system/silos/s/scim", + "siloSubnetPools": "/system/silos/s/subnet-pools", "siloUtilization": "/utilization", "silos": "/system/silos", "silosNew": "/system/silos-new", @@ -107,6 +109,11 @@ test('path builder', () => { "sshKeyEdit": "/settings/ssh-keys/ss/edit", "sshKeys": "/settings/ssh-keys", "sshKeysNew": "/settings/ssh-keys-new", + "subnetPool": "/system/networking/subnet-pools/sp", + "subnetPoolEdit": "/system/networking/subnet-pools/sp/edit", + "subnetPoolMemberAdd": "/system/networking/subnet-pools/sp/members-add", + "subnetPools": "/system/networking/subnet-pools", + "subnetPoolsNew": "/system/networking/subnet-pools-new", "systemUpdate": "/system/update", "systemUtilization": "/system/utilization", "vpc": "/projects/p/vpcs/v/firewall-rules", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index 07c587743c..468a4e7141 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -124,6 +124,12 @@ export const pb = { ipPoolEdit: (params: PP.IpPool) => `${pb.ipPool(params)}/edit`, ipPoolRangeAdd: (params: PP.IpPool) => `${pb.ipPool(params)}/ranges-add`, + subnetPools: () => '/system/networking/subnet-pools', + subnetPoolsNew: () => '/system/networking/subnet-pools-new', + subnetPool: (params: PP.SubnetPool) => `${pb.subnetPools()}/${params.subnetPool}`, + subnetPoolEdit: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/edit`, + subnetPoolMemberAdd: (params: PP.SubnetPool) => `${pb.subnetPool(params)}/members-add`, + sledInventory: () => `${inventoryBase()}/sleds`, diskInventory: () => `${inventoryBase()}/disks`, sledInstances: ({ sledId }: PP.Sled) => `${pb.sledInventory()}/${sledId}/instances`, @@ -135,6 +141,7 @@ export const pb = { siloIdps: (params: PP.Silo) => `${siloBase(params)}/idps`, siloIdpsNew: (params: PP.Silo) => `${siloBase(params)}/idps-new`, siloIpPools: (params: PP.Silo) => `${siloBase(params)}/ip-pools`, + siloSubnetPools: (params: PP.Silo) => `${siloBase(params)}/subnet-pools`, siloQuotas: (params: PP.Silo) => `${siloBase(params)}/quotas`, siloFleetRoles: (params: PP.Silo) => `${siloBase(params)}/fleet-roles`, siloScim: (params: PP.Silo) => `${siloBase(params)}/scim`, diff --git a/app/util/path-params.ts b/app/util/path-params.ts index ed103c65d4..011afa41c3 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -29,4 +29,5 @@ export type VpcInternetGateway = Required export type SshKey = Required export type AffinityGroup = Required export type AntiAffinityGroup = Required +export type SubnetPool = Required export type Disk = Required diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index f7f3781657..0c1533f918 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -414,6 +414,49 @@ export const lookup = { if (!pool) throw notFoundErr('no default subnet pool configured') return pool }, + /** System-scoped subnet pool view (no is_default) */ + systemSubnetPool({ subnetPool: id }: Sel.SubnetPool): Json { + const pool = lookup.subnetPool({ subnetPool: id }) + // Strip is_default for system-scoped view + const { is_default: _, ...systemPool } = pool + return systemPool + }, + siloSubnetPool(path: Sel.SubnetPool & Sel.Silo): Json { + const silo = lookup.silo(path) + const pool = lookup.subnetPool(path) + const link = db.subnetPoolSilos.find( + (sps) => sps.subnet_pool_id === pool.id && sps.silo_id === silo.id + ) + if (!link) { + throw notFoundErr(`link for subnet pool '${path.subnetPool}' and silo '${path.silo}'`) + } + return { ...pool, is_default: link.is_default } + }, + siloSubnetPools(path: Sel.Silo): Json[] { + const silo = lookup.silo(path) + return db.subnetPoolSilos + .filter((link) => link.silo_id === silo.id) + .map((link) => { + const pool = db.subnetPools.find((p) => p.id === link.subnet_pool_id) + if (!pool) { + const linkStr = JSON.stringify(link) + const message = `Found subnet pool-silo link without corresponding pool: ${linkStr}` + throw json({ message }, { status: 500 }) + } + return { ...pool, is_default: link.is_default } + }) + }, + subnetPoolSiloLink(path: Sel.SubnetPool & Sel.Silo): Json { + const pool = lookup.subnetPool(path) + const silo = lookup.silo(path) + const link = db.subnetPoolSilos.find( + (sps) => sps.subnet_pool_id === pool.id && sps.silo_id === silo.id + ) + if (!link) { + throw notFoundErr(`link for subnet pool '${path.subnetPool}' and silo '${path.silo}'`) + } + return link + }, ipPool({ pool: id }: Sel.IpPool): Json { if (!id) throw notFoundErr('no pool specified') @@ -570,6 +613,7 @@ const initDb = { externalSubnets: [...mock.externalSubnets], subnetPools: [...mock.subnetPools], subnetPoolMembers: [...mock.subnetPoolMembers], + subnetPoolSilos: [...mock.subnetPoolSilos], floatingIps: [...mock.floatingIps], userGroups: [...mock.userGroups], /** Join table for `users` and `userGroups` */ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 212f3567d0..4f8052e47c 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -1263,7 +1263,7 @@ export const handlers = makeHandlers({ // could be different. Need to fix that. Should 400 or 409 on conflict. if (!alreadyThere) db.ipPoolSilos.push(assoc) - return assoc + return json(assoc, { status: 201 }) }, systemIpPoolSiloUnlink({ path, cookies }) { requireFleetAdmin(cookies) @@ -2299,6 +2299,243 @@ export const handlers = makeHandlers({ db.scimTokens = db.scimTokens.filter((t) => t.id !== token.id) return 204 }, + subnetPoolList({ query, cookies }) { + const user = currentUser(cookies) + const pools = lookup.siloSubnetPools({ silo: user.silo_id }) + return paginated(query, pools) + }, + subnetPoolView: ({ path: { pool }, cookies }) => { + const user = currentUser(cookies) + return lookup.siloSubnetPool({ subnetPool: pool, silo: user.silo_id }) + }, + systemSubnetPoolList({ query, cookies }) { + requireFleetViewer(cookies) + // Return system-scoped pools (without is_default) + const pools = db.subnetPools.map(({ is_default: _, ...p }) => p) + return paginated(query, pools) + }, + systemSubnetPoolView({ path, cookies }) { + requireFleetViewer(cookies) + return lookup.systemSubnetPool({ subnetPool: path.pool }) + }, + systemSubnetPoolCreate({ body, cookies }) { + requireFleetAdmin(cookies) + errIfExists(db.subnetPools, { name: body.name }, 'subnet pool') + + const newPool: Json = { + id: uuid(), + description: body.description, + name: body.name, + ip_version: body.ip_version || 'v4', + is_default: false, + ...getTimestamps(), + } + db.subnetPools.push(newPool) + + const { is_default: _, ...systemPool } = newPool + return json(systemPool, { status: 201 }) + }, + systemSubnetPoolUpdate({ path, body, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + + if (body.name) { + if (body.name !== pool.name) { + errIfExists(db.subnetPools, { name: body.name }) + } + pool.name = body.name + } + + updateDesc(pool, body) + + const { is_default: _, ...systemPool } = pool + return systemPool + }, + systemSubnetPoolDelete({ path, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + + if (db.subnetPoolMembers.some((m) => m.subnet_pool_id === pool.id)) { + throw 'Subnet pool cannot be deleted while it contains members' + } + + db.subnetPools = db.subnetPools.filter((p) => p.id !== pool.id) + db.subnetPoolSilos = db.subnetPoolSilos.filter((s) => s.subnet_pool_id !== pool.id) + + return 204 + }, + systemSubnetPoolMemberList({ path, query, cookies }) { + requireFleetViewer(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + const members = db.subnetPoolMembers.filter((m) => m.subnet_pool_id === pool.id) + return paginated(query, members) + }, + systemSubnetPoolMemberAdd({ path, body, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + + const parsed = parseIpNet(body.subnet) + if (parsed.type === 'error') { + throw `Invalid subnet CIDR: ${parsed.message}` + } + if (parsed.type !== pool.ip_version) { + throw `IP${parsed.type} subnet not allowed in IP${pool.ip_version} pool` + } + + const maxBound = pool.ip_version === 'v4' ? 32 : 128 + const minPL = body.min_prefix_length ?? parsed.width + const maxPL = body.max_prefix_length ?? maxBound + + if (minPL > maxPL) { + throw 'min_prefix_length must be <= max_prefix_length' + } + if (minPL < parsed.width || maxPL < parsed.width) { + throw `Prefix lengths must be >= subnet prefix length (${parsed.width})` + } + + // reject overlapping members within the same pool + const existing = db.subnetPoolMembers.filter((m) => m.subnet_pool_id === pool.id) + for (const m of existing) { + if (m.subnet === body.subnet) { + throw `Overlapping member: ${body.subnet} already exists in pool` + } + } + + const newMember: Json = { + id: uuid(), + subnet_pool_id: pool.id, + subnet: body.subnet, + min_prefix_length: minPL, + max_prefix_length: maxPL, + time_created: new Date().toISOString(), + } + + db.subnetPoolMembers.push(newMember) + + return json(newMember, { status: 201 }) + }, + systemSubnetPoolMemberRemove({ path, body, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + + const memberIdsToDelete = db.subnetPoolMembers + .filter((m) => m.subnet_pool_id === pool.id && m.subnet === body.subnet) + .map((m) => m.id) + + if (memberIdsToDelete.length === 0) throw notFoundErr(`subnet member ${body.subnet}`) + + const inUse = db.externalSubnets.some((s) => + memberIdsToDelete.includes(s.subnet_pool_member_id) + ) + if (inUse) { + throw 'Subnet pool member cannot be removed while subnets are allocated from it' + } + + db.subnetPoolMembers = db.subnetPoolMembers.filter( + (m) => !memberIdsToDelete.includes(m.id) + ) + + return 204 + }, + systemSubnetPoolSiloList({ path, cookies }) { + requireFleetViewer(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + const assocs = db.subnetPoolSilos.filter((sps) => sps.subnet_pool_id === pool.id) + return { items: assocs } + }, + systemSubnetPoolSiloLink({ path, body, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + const silo_id = lookup.silo({ silo: body.silo }).id + + const assoc: Json = { + subnet_pool_id: pool.id, + silo_id, + is_default: body.is_default, + } + + const alreadyThere = db.subnetPoolSilos.find( + (sps) => sps.subnet_pool_id === pool.id && sps.silo_id === silo_id + ) + + if (!alreadyThere) db.subnetPoolSilos.push(assoc) + + return json(assoc, { status: 201 }) + }, + systemSubnetPoolSiloUnlink({ path, cookies }) { + requireFleetAdmin(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + const silo = lookup.silo(path) + + // Reject if any external subnets from this pool are in projects owned by this silo + const siloProjectIds = new Set( + db.projects.filter((p) => p.silo_id === silo.id).map((p) => p.id) + ) + const hasAllocatedSubnets = db.externalSubnets.some( + (s) => s.subnet_pool_id === pool.id && siloProjectIds.has(s.project_id) + ) + if (hasAllocatedSubnets) { + throw 'Cannot unlink silo: external subnets from this pool are still allocated in the silo' + } + + db.subnetPoolSilos = db.subnetPoolSilos.filter( + (sps) => !(sps.subnet_pool_id === pool.id && sps.silo_id === silo.id) + ) + + return 204 + }, + systemSubnetPoolSiloUpdate({ path, body, cookies }) { + requireFleetAdmin(cookies) + const link = lookup.subnetPoolSiloLink({ subnetPool: path.pool, ...path }) + + // if setting default, clear existing default for same IP version + if (body.is_default) { + const silo = lookup.silo(path) + const currentPool = lookup.subnetPool({ subnetPool: link.subnet_pool_id }) + + const existingDefault = db.subnetPoolSilos.find((sps) => { + if (sps.silo_id !== silo.id || !sps.is_default) return false + const pool = db.subnetPools.find((p) => p.id === sps.subnet_pool_id) + return pool && pool.ip_version === currentPool.ip_version + }) + + if (existingDefault) { + existingDefault.is_default = false + } + } + + link.is_default = body.is_default + + return link + }, + systemSubnetPoolUtilizationView({ path, cookies }) { + requireFleetViewer(cookies) + const pool = lookup.subnetPool({ subnetPool: path.pool }) + + // TODO: figure out why subnet pool utilization can't list remaining + // addresses like IP pools do and make this mock match omicron's behavior. + // Also, Math.pow(2, bits - prefix) overflows to Infinity for IPv6. + const members = db.subnetPoolMembers.filter((m) => m.subnet_pool_id === pool.id) + + let capacity = 0 + for (const member of members) { + const prefixMatch = member.subnet.match(/\/(\d+)$/) + const prefix = prefixMatch ? Number(prefixMatch[1]) : 0 + const bits = pool.ip_version === 'v4' ? 32 : 128 + capacity += Math.pow(2, bits - prefix) + } + + const allocated = db.externalSubnets.filter( + (es) => es.subnet_pool_id === pool.id + ).length + + return { allocated, capacity } + }, + siloSubnetPoolList({ path, query, cookies }) { + requireFleetViewer(cookies) + const pools = lookup.siloSubnetPools(path) + return paginated(query, pools) + }, // Misc endpoints we're not using yet in the console affinityGroupCreate: NotImplemented, @@ -2322,10 +2559,6 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - subnetPoolList({ query }) { - return paginated(query, db.subnetPools) - }, - subnetPoolView: ({ path }) => lookup.subnetPool({ subnetPool: path.pool }), instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, @@ -2397,24 +2630,10 @@ export const handlers = makeHandlers({ rackView: NotImplemented, siloPolicyUpdate: NotImplemented, siloPolicyView: NotImplemented, - siloSubnetPoolList: NotImplemented, siloUserList: NotImplemented, siloUserView: NotImplemented, sledListUninitialized: NotImplemented, sledSetProvisionPolicy: NotImplemented, - systemSubnetPoolCreate: NotImplemented, - systemSubnetPoolDelete: NotImplemented, - systemSubnetPoolList: NotImplemented, - systemSubnetPoolMemberAdd: NotImplemented, - systemSubnetPoolMemberList: NotImplemented, - systemSubnetPoolMemberRemove: NotImplemented, - systemSubnetPoolSiloLink: NotImplemented, - systemSubnetPoolSiloList: NotImplemented, - systemSubnetPoolSiloUnlink: NotImplemented, - systemSubnetPoolSiloUpdate: NotImplemented, - systemSubnetPoolUpdate: NotImplemented, - systemSubnetPoolUtilizationView: NotImplemented, - systemSubnetPoolView: NotImplemented, supportBundleCreate: NotImplemented, supportBundleDelete: NotImplemented, supportBundleDownload: NotImplemented, diff --git a/mock-api/subnet-pool.ts b/mock-api/subnet-pool.ts index 98803a779b..f9c36fbde3 100644 --- a/mock-api/subnet-pool.ts +++ b/mock-api/subnet-pool.ts @@ -6,13 +6,10 @@ * Copyright Oxide Computer Company */ -import type { SiloSubnetPool, SubnetPoolMember } from '@oxide/api' +import type { SiloSubnetPool, SubnetPoolMember, SubnetPoolSiloLink } from '@oxide/api' import type { Json } from './json-type' - -// Minimal subnet pool seed data for external subnet allocation. -// There's no UI for subnet pools themselves yet, but external subnets -// reference them. +import { defaultSilo, myriadSilo } from './silo' export const subnetPool1: Json = { id: '41e54fcd-c45b-43ed-90fb-4b9faf24e167', @@ -44,7 +41,17 @@ export const subnetPool3: Json = { time_modified: new Date().toISOString(), } -export const subnetPools = [subnetPool1, subnetPool2, subnetPool3] +export const subnetPool4: Json = { + id: 'b68e8311-8809-4e12-bdd6-0ba1cf39d892', + name: 'myriad-v4-subnet-pool', + description: 'IPv4 subnet pool for myriad silo', + ip_version: 'v4', + is_default: false, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const subnetPools = [subnetPool1, subnetPool2, subnetPool3, subnetPool4] export const subnetPoolMember1: Json = { id: '0466eafd-2922-4360-a0ee-e4c99b370c04', @@ -74,4 +81,58 @@ export const subnetPoolMember3: Json = { time_created: new Date().toISOString(), } -export const subnetPoolMembers = [subnetPoolMember1, subnetPoolMember2, subnetPoolMember3] +export const subnetPoolMember4: Json = { + id: '1f138054-4db0-45cb-8cf7-0a4027d7e6b4', + subnet_pool_id: subnetPool4.id, + subnet: '192.168.0.0/16', + min_prefix_length: 20, + max_prefix_length: 28, + time_created: new Date().toISOString(), +} + +export const subnetPoolMembers = [ + subnetPoolMember1, + subnetPoolMember2, + subnetPoolMember3, + subnetPoolMember4, +] + +// Link pools to mock silo +export const subnetPoolSilo1: Json = { + subnet_pool_id: subnetPool1.id, + silo_id: defaultSilo.id, + is_default: true, +} + +export const subnetPoolSilo2: Json = { + subnet_pool_id: subnetPool2.id, + silo_id: defaultSilo.id, + is_default: false, +} + +export const subnetPoolSilo3: Json = { + subnet_pool_id: subnetPool3.id, + silo_id: defaultSilo.id, + is_default: false, +} + +// myriad silo: pool1 as default, pool4 as non-default +export const subnetPoolSilo4: Json = { + subnet_pool_id: subnetPool1.id, + silo_id: myriadSilo.id, + is_default: true, +} + +export const subnetPoolSilo5: Json = { + subnet_pool_id: subnetPool4.id, + silo_id: myriadSilo.id, + is_default: false, +} + +export const subnetPoolSilos = [ + subnetPoolSilo1, + subnetPoolSilo2, + subnetPoolSilo3, + subnetPoolSilo4, + subnetPoolSilo5, +] diff --git a/test/e2e/subnet-pools.e2e.ts b/test/e2e/subnet-pools.e2e.ts new file mode 100644 index 0000000000..e64b629162 --- /dev/null +++ b/test/e2e/subnet-pools.e2e.ts @@ -0,0 +1,289 @@ +/* + * 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 { expect, test } from '@playwright/test' + +import { + clickRowAction, + expectRowVisible, + expectToast, + fillNumberInput, + getPageAsUser, + selectOption, +} from './utils' + +test('Subnet pool list', async ({ page }) => { + await page.goto('/system/networking/subnet-pools') + await expect(page).toHaveTitle('Subnet Pools / Oxide Console') + await expect(page.getByRole('heading', { name: 'Subnet Pools' })).toBeVisible() + + const table = page.getByRole('table') + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 pools + + await expectRowVisible(table, { name: 'default-v4-subnet-pool' }) + await expectRowVisible(table, { name: 'ipv6-subnet-pool' }) + await expectRowVisible(table, { name: 'myriad-v4-subnet-pool' }) + await expectRowVisible(table, { name: 'secondary-v4-subnet-pool' }) +}) + +test('Subnet pool create', async ({ page }) => { + await page.goto('/system/networking/subnet-pools') + + await page.getByRole('link', { name: 'New Subnet Pool' }).click() + await expect(page).toHaveURL('/system/networking/subnet-pools-new') + + await page.getByRole('textbox', { name: 'Name' }).fill('test-pool') + await page.getByRole('textbox', { name: 'Description' }).fill('A test subnet pool') + await page.getByRole('button', { name: 'Create subnet pool' }).click() + + await expectToast(page, 'Subnet pool test-pool created') + + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'test-pool' }) +}) + +test('Subnet pool detail and members', async ({ page }) => { + await page.goto('/system/networking/subnet-pools') + + await page.getByRole('link', { name: 'default-v4-subnet-pool' }).click() + await expect(page).toHaveTitle('default-v4-subnet-pool / Subnet Pools / Oxide Console') + + // Check properties table + await expect(page.getByText('Default IPv4 subnet pool')).toBeVisible() + + // Members tab should show existing member + const membersTable = page.getByRole('table') + await expectRowVisible(membersTable, { Subnet: '10.128.0.0/16' }) +}) + +test('Subnet pool add member', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool') + + await page.getByRole('link', { name: 'Add member' }).click() + await expect(page).toHaveURL( + '/system/networking/subnet-pools/default-v4-subnet-pool/members-add' + ) + + await page.getByRole('textbox', { name: 'Subnet' }).fill('172.16.0.0/12') + await fillNumberInput(page.getByRole('textbox', { name: 'Min prefix length' }), '16') + await fillNumberInput(page.getByRole('textbox', { name: 'Max prefix length' }), '24') + await page.getByRole('dialog').getByRole('button', { name: 'Add member' }).click() + + await expectToast(page, 'Member added') + + const table = page.getByRole('table') + await expectRowVisible(table, { Subnet: '172.16.0.0/12' }) +}) + +test('Subnet pool remove member', async ({ page }) => { + // Use secondary pool — default pool's member has external subnets allocated from it + await page.goto('/system/networking/subnet-pools/secondary-v4-subnet-pool') + + await clickRowAction(page, '172.20.0.0/16', 'Remove') + await expect(page.getByRole('dialog', { name: 'Confirm remove' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + // The row should be gone + await expect(page.getByRole('cell', { name: '172.20.0.0/16' })).toBeHidden() +}) + +test('Subnet pool linked silos', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': 'default' }) + + // Clicking silo link goes to silo's subnet pools tab + const siloLink = page.getByRole('link', { name: 'maze-war' }) + await siloLink.click() + await expect(page).toHaveURL('/system/silos/maze-war/subnet-pools') + await page.goBack() + + // Unlink fails when silo still has external subnets allocated from the pool + await clickRowAction(page, 'maze-war', 'Unlink') + await expect(page.getByRole('dialog', { name: 'Confirm unlink' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'Could not unlink silo') + // Row should still be there + await expectRowVisible(table, { Silo: 'maze-war' }) +}) + +test('Subnet pool unlink silo succeeds when no subnets allocated', async ({ page }) => { + // ipv6-subnet-pool is linked to maze-war but has no external subnets allocated + await page.goto('/system/networking/subnet-pools/ipv6-subnet-pool?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war' }) + + await clickRowAction(page, 'maze-war', 'Unlink') + await expect(page.getByRole('dialog', { name: 'Confirm unlink' })).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByRole('link', { name: 'maze-war' })).toBeHidden() +}) + +test('Subnet pool link silo', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/secondary-v4-subnet-pool?tab=silos') + + await page.getByRole('button', { name: 'Link silo' }).first().click() + const dialog = page.getByRole('dialog', { name: 'Link silo' }) + await expect(dialog).toBeVisible() + + await page.getByPlaceholder('Select a silo').fill('m') + await page.getByRole('option', { name: 'myriad' }).click() + await dialog.getByRole('button', { name: 'Link' }).click() + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'myriad' }) +}) + +test('Subnet pool silo make default (no existing default)', async ({ page }) => { + // ipv6-subnet-pool is linked to maze-war but not as default, and maze-war has no v6 default + await page.goto('/system/networking/subnet-pools/ipv6-subnet-pool?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': '' }) + + await clickRowAction(page, 'maze-war', 'Make default') + + const dialog = page.getByRole('dialog', { name: 'Confirm make default' }) + await expect( + dialog.getByText( + 'Are you sure you want to make ipv6-subnet-pool the default IPv6 subnet pool for silo maze-war?' + ) + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': 'default' }) +}) + +test('Subnet pool silo make default (with existing default)', async ({ page }) => { + // secondary-v4-subnet-pool is linked to maze-war but not as default; + // default-v4-subnet-pool is the v4 default for maze-war + await page.goto('/system/networking/subnet-pools/secondary-v4-subnet-pool?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': '' }) + + await clickRowAction(page, 'maze-war', 'Make default') + + const dialog = page.getByRole('dialog', { name: 'Confirm change default' }) + await expect( + dialog.getByText( + 'Are you sure you want to change the default IPv4 subnet pool for silo maze-war from default-v4-subnet-pool to secondary-v4-subnet-pool?' + ) + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': 'default' }) +}) + +test('Subnet pool silo clear default', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool?tab=silos') + + const table = page.getByRole('table') + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': 'default' }) + + await clickRowAction(page, 'maze-war', 'Clear default') + + const dialog = page.getByRole('dialog', { name: 'Confirm clear default' }) + await expect( + dialog.getByText( + 'Are you sure you want default-v4-subnet-pool to stop being the default IPv4 subnet pool for silo maze-war?' + ) + ).toBeVisible() + + await page.getByRole('button', { name: 'Confirm' }).click() + await expectRowVisible(table, { Silo: 'maze-war', 'Silo default': '' }) +}) + +test('Subnet pool edit', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool') + + await page.getByRole('button', { name: 'Subnet pool actions' }).click() + await page.getByRole('menuitem', { name: 'Edit' }).click() + + const nameField = page.getByRole('textbox', { name: 'Name' }) + await expect(nameField).toHaveValue('default-v4-subnet-pool') + await nameField.fill('renamed-pool') + await page.getByRole('button', { name: 'Update subnet pool' }).click() + + await expectToast(page, 'Subnet pool renamed-pool updated') +}) + +test('Subnet pool delete', async ({ page }) => { + // First remove the member so the pool can be deleted + await page.goto('/system/networking/subnet-pools/secondary-v4-subnet-pool') + await clickRowAction(page, '172.20.0.0/16', 'Remove') + const removeDialog = page.getByRole('dialog', { name: 'Confirm remove' }) + await expect(removeDialog).toBeVisible() + await removeDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByRole('cell', { name: '172.20.0.0/16' })).toBeHidden() + + // Use client-side navigation to preserve MSW db state + await page.getByLabel('Breadcrumbs').getByRole('link', { name: 'Subnet Pools' }).click() + await clickRowAction(page, 'secondary-v4-subnet-pool', 'Delete') + const deleteDialog = page.getByRole('dialog', { name: 'Confirm delete' }) + await expect(deleteDialog).toBeVisible() + await deleteDialog.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByRole('cell', { name: 'secondary-v4-subnet-pool' })).toBeHidden() +}) + +test('Subnet pool delete disabled when pool has members', async ({ page }) => { + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool') + + await page.getByRole('button', { name: 'Subnet pool actions' }).click() + const deleteItem = page.getByRole('menuitem', { name: 'Delete' }) + await expect(deleteItem).toBeDisabled() +}) + +test('Silo subnet pools tab', async ({ page }) => { + await page.goto('/system/silos/maze-war/subnet-pools') + await expect(page).toHaveTitle('Subnet Pools / maze-war / Silos / Oxide Console') + + const table = page.getByRole('table') + // name cell includes "default" badge text + await expectRowVisible(table, { + name: expect.stringContaining('default-v4-subnet-pool'), + }) +}) + +test('Subnet pool picker only shows silo-linked pools', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Aryeh Kosman') + await page.goto('/projects/kosman-project/external-subnets-new') + + // myriad silo has default-v4-subnet-pool and myriad-v4-subnet-pool linked + const poolButton = page.getByRole('button', { name: 'Subnet pool' }) + await poolButton.click() + await expect(page.getByRole('option', { name: 'default-v4-subnet-pool' })).toBeVisible() + await expect(page.getByRole('option', { name: 'myriad-v4-subnet-pool' })).toBeVisible() + + // pools not linked to myriad should not appear + await expect(page.getByRole('option', { name: 'secondary-v4-subnet-pool' })).toBeHidden() + await expect(page.getByRole('option', { name: 'ipv6-subnet-pool' })).toBeHidden() +}) + +test('External subnet create with myriad silo pool', async ({ browser }) => { + const page = await getPageAsUser(browser, 'Aryeh Kosman') + await page.goto('/projects/kosman-project/external-subnets-new') + + await page.getByRole('textbox', { name: 'Name' }).fill('myriad-subnet') + + // default pool should be preselected + await expect(page.getByRole('button', { name: 'Subnet pool' })).toContainText( + 'default-v4-subnet-pool' + ) + + // switch to the myriad-only pool + const option = page.getByRole('option', { name: 'myriad-v4-subnet-pool' }) + await selectOption(page, 'Subnet pool', option) + + await page.getByRole('button', { name: 'Create external subnet' }).click() + await expectToast(page, 'External subnet myriad-subnet created') + + await expectRowVisible(page.getByRole('table'), { name: 'myriad-subnet' }) +})