(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 (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/pages/system/networking/SubnetPoolsPage.tsx b/app/pages/system/networking/SubnetPoolsPage.tsx
new file mode 100644
index 000000000..5a299167b
--- /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 af0161aa8..df6bccb7d 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 af6875dca..26ad9ee9a 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 000000000..a59601c62
--- /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 (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/app/routes.tsx b/app/routes.tsx
index 8795c29f5..5d07ce94b 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 ad7d56793..0ac260b92 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 b919ed509..7c9fcfbf5 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 c3538b66b..ebd7d3e3d 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 07c587743..468a4e714 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 ed103c65d..011afa41c 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 f7f378165..0c1533f91 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 212f3567d..4f8052e47 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 98803a779..f9c36fbde 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 000000000..e64b62916
--- /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' })
+})