From d74a490da5593ebde72b156e6e53c426bfe898ed Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 2 Apr 2026 18:01:30 -0400 Subject: [PATCH] subnet pool utilization --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 12 +++--- app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/validate.ts | 6 ++- app/forms/subnet-pool-member-add.tsx | 1 + app/pages/system/networking/IpPoolPage.tsx | 6 +-- app/pages/system/networking/IpPoolsPage.tsx | 5 +-- .../system/networking/SubnetPoolPage.tsx | 14 ++++++- .../system/networking/SubnetPoolsPage.tsx | 18 +++++++- app/ui/lib/BigNum.tsx | 17 ++++++++ mock-api/msw/handlers.ts | 42 +++++++++++-------- test/e2e/subnet-pools.e2e.ts | 41 ++++++++++++++++-- 12 files changed, 124 insertions(+), 42 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index bf2cdf03dc..5d15d27bea 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -d7c3b00d743bcc9212b222a74ae27cc970b1ee2c +254a0c51bc0beecb79c8a9dfccce8e7bc35b5ca4 diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index b19107ddec..5edbefb347 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -4626,13 +4626,15 @@ export type SubnetPoolSiloUpdate = { export type SubnetPoolUpdate = { description?: string | null; name?: Name | null } /** - * Utilization information for a subnet pool + * Utilization of addresses in a subnet pool. + * + * Note that both the count of remaining addresses and the total capacity are integers, reported as floating point numbers. This accommodates allocations larger than a 64-bit integer, which is common with IPv6 address spaces. With very large subnet pools (> 2**53 addresses), integer precision will be lost, in exchange for representing the entire range. In such a case the pool still has many available addresses. */ export type SubnetPoolUtilization = { - /** Number of addresses allocated from this pool */ - allocated: number - /** Total capacity of this pool in addresses */ + /** The total number of addresses in the pool. */ capacity: number + /** The number of remaining addresses in the pool. */ + remaining: number } export type SupportBundleCreate = { @@ -7480,7 +7482,7 @@ export class Api { * Pulled from info.version in the OpenAPI schema. Sent in the * `api-version` header on all requests. */ - apiVersion = '2026032400.0.0' + apiVersion = '2026032500.0.0' constructor({ host = '', baseParams = {}, token }: ApiConfig = {}) { this.host = host diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 2b381dbd86..b8bd5a8887 100644 --- a/app/api/__generated__/OMICRON_VERSION +++ b/app/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -d7c3b00d743bcc9212b222a74ae27cc970b1ee2c +254a0c51bc0beecb79c8a9dfccce8e7bc35b5ca4 diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index a36aad625d..0c46dc8e5d 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -4218,11 +4218,13 @@ export const SubnetPoolUpdate = z.preprocess( ) /** - * Utilization information for a subnet pool + * Utilization of addresses in a subnet pool. + * + * Note that both the count of remaining addresses and the total capacity are integers, reported as floating point numbers. This accommodates allocations larger than a 64-bit integer, which is common with IPv6 address spaces. With very large subnet pools (> 2**53 addresses), integer precision will be lost, in exchange for representing the entire range. In such a case the pool still has many available addresses. */ export const SubnetPoolUtilization = z.preprocess( processResponseBody, - z.object({ allocated: z.number(), capacity: z.number() }) + z.object({ capacity: z.number(), remaining: z.number() }) ) export const SupportBundleCreate = z.preprocess( diff --git a/app/forms/subnet-pool-member-add.tsx b/app/forms/subnet-pool-member-add.tsx index 7d15d5a4c9..9327c90915 100644 --- a/app/forms/subnet-pool-member-add.tsx +++ b/app/forms/subnet-pool-member-add.tsx @@ -117,6 +117,7 @@ export default function SubnetPoolMemberAdd() { const addMember = useApiMutation(api.systemSubnetPoolMemberAdd, { onSuccess() { queryClient.invalidateEndpoint('systemSubnetPoolMemberList') + queryClient.invalidateEndpoint('systemSubnetPoolUtilizationView') addToast({ content: 'Member added' }) onDismiss() }, diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 7c9bf6bb43..4177afbac4 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -42,7 +42,7 @@ 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 { BigNum } from '~/ui/lib/BigNum' +import { UtilizationFraction } from '~/ui/lib/BigNum' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import * as Dropdown from '~/ui/lib/DropdownMenu' @@ -209,9 +209,7 @@ function PoolProperties() { - - {' / '} - + diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 70c3be5167..914a841ca5 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -26,7 +26,7 @@ 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 { BigNum } from '~/ui/lib/BigNum' +import { UtilizationFraction } from '~/ui/lib/BigNum' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -50,8 +50,7 @@ function UtilizationCell({ pool }: { pool: string }) { if (!data) return return (
- /{' '} - +
) } diff --git a/app/pages/system/networking/SubnetPoolPage.tsx b/app/pages/system/networking/SubnetPoolPage.tsx index cc15b4fea9..3403b133ed 100644 --- a/app/pages/system/networking/SubnetPoolPage.tsx +++ b/app/pages/system/networking/SubnetPoolPage.tsx @@ -42,6 +42,7 @@ 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 { UtilizationFraction } from '~/ui/lib/BigNum' import { toComboboxItems } from '~/ui/lib/Combobox' import { CreateButton, CreateLink } from '~/ui/lib/CreateButton' import * as Dropdown from '~/ui/lib/DropdownMenu' @@ -65,6 +66,8 @@ 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 subnetPoolUtilizationView = ({ subnetPool }: PP.SubnetPool) => + q(api.systemSubnetPoolUtilizationView, { path: { pool: subnetPool } }) const siloSubnetPoolList = (silo: string) => q(api.siloSubnetPoolList, { path: { silo }, query: { limit: ALL_ISH } }) @@ -78,6 +81,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { } }), queryClient.prefetchQuery(subnetPoolMemberList(selector).optionsFn()), + queryClient.prefetchQuery(subnetPoolUtilizationView(selector)), queryClient.fetchQuery(siloList).then((silos) => { for (const silo of silos.items) { queryClient.setQueryData(siloView({ silo: silo.id }).queryKey, silo) @@ -154,6 +158,7 @@ export default function SubnetPoolPage() { function PoolProperties() { const poolSelector = useSubnetPoolSelector() const { data: pool } = usePrefetchedQuery(subnetPoolView(poolSelector)) + const { data: utilization } = usePrefetchedQuery(subnetPoolUtilizationView(poolSelector)) return ( @@ -162,9 +167,13 @@ function PoolProperties() { - {/* TODO: add utilization row once Nexus endpoint is implemented - https://github.com/oxidecomputer/omicron/issues/10109 */} + + + + + + ) } @@ -183,6 +192,7 @@ function MembersTable() { const { mutateAsync: removeMember } = useApiMutation(api.systemSubnetPoolMemberRemove, { onSuccess() { queryClient.invalidateEndpoint('systemSubnetPoolMemberList') + queryClient.invalidateEndpoint('systemSubnetPoolUtilizationView') }, }) const emptyState = ( diff --git a/app/pages/system/networking/SubnetPoolsPage.tsx b/app/pages/system/networking/SubnetPoolsPage.tsx index 5a299167b4..20cb35a816 100644 --- a/app/pages/system/networking/SubnetPoolsPage.tsx +++ b/app/pages/system/networking/SubnetPoolsPage.tsx @@ -27,10 +27,12 @@ import { IpVersionBadge } from '~/components/IpVersionBadge' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' +import { SkeletonCell } from '~/table/cells/EmptyCell' 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 { UtilizationFraction } from '~/ui/lib/BigNum' import { CreateLink } from '~/ui/lib/CreateButton' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' @@ -49,10 +51,18 @@ const EmptyState = () => ( /> ) +function UtilizationCell({ pool }: { pool: string }) { + const { data } = useQuery(q(api.systemSubnetPoolUtilizationView, { path: { pool } })) + if (!data) return + return ( +
+ +
+ ) +} + 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 })), @@ -62,6 +72,10 @@ const staticColumns = [ header: 'Version', cell: (info) => , }), + colHelper.display({ + header: 'Addresses remaining', + cell: (info) => , + }), colHelper.accessor('timeCreated', Columns.timeCreated), ] diff --git a/app/ui/lib/BigNum.tsx b/app/ui/lib/BigNum.tsx index ffd2f782bd..ea78494e23 100644 --- a/app/ui/lib/BigNum.tsx +++ b/app/ui/lib/BigNum.tsx @@ -23,3 +23,20 @@ export function BigNum({ num, className }: { num: number | bigint; className?: s return {inner} } + +/** Display `remaining / capacity` with BigNum formatting. */ +export function UtilizationFraction({ + remaining, + capacity, +}: { + remaining: number | bigint + capacity: number | bigint +}) { + return ( + <> + + {' / '} + + + ) +} diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index ff3199ca12..cbda97edec 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -2511,25 +2511,31 @@ export const handlers = makeHandlers({ systemSubnetPoolUtilizationView({ path, cookies }) { requireFleetViewer(cookies) const pool = lookup.subnetPool({ subnetPool: path.pool }) + const bits = pool.ip_version === 'v4' ? 32 : 128 + + // Unlike IP pool utilization (which uses bigint arithmetic because IP + // ranges have arbitrary sizes), subnet sizes are always powers of 2, which + // are exactly representable as f64 up to 2^1023. So Math.pow is exact for + // each term and we don't need bigint intermediate arithmetic. + const subnetSize = (cidr: string) => { + const parsed = parseIpNet(cidr) + // mock data is always valid, so this is just for the type narrowing + if (parsed.type === 'error') return 0 + return Math.pow(2, bits - parsed.width) + } + + const capacity = R.pipe( + db.subnetPoolMembers, + R.filter((m) => m.subnet_pool_id === pool.id), + R.sumBy((m) => subnetSize(m.subnet)) + ) + const allocated = R.pipe( + db.externalSubnets, + R.filter((s) => s.subnet_pool_id === pool.id), + R.sumBy((s) => subnetSize(s.subnet)) + ) - // 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 } + return { capacity, remaining: capacity - allocated } }, siloSubnetPoolList({ path, query, cookies }) { requireFleetViewer(cookies) diff --git a/test/e2e/subnet-pools.e2e.ts b/test/e2e/subnet-pools.e2e.ts index e64b629162..d7a95a08ef 100644 --- a/test/e2e/subnet-pools.e2e.ts +++ b/test/e2e/subnet-pools.e2e.ts @@ -25,10 +25,22 @@ test('Subnet pool list', async ({ page }) => { 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' }) + await expectRowVisible(table, { + name: 'default-v4-subnet-pool', + 'Addresses remaining': '65,008 / 65,536', + }) + await expectRowVisible(table, { + name: 'ipv6-subnet-pool', + 'Addresses remaining': '79.2e27 / 79.2e27', + }) + await expectRowVisible(table, { + name: 'myriad-v4-subnet-pool', + 'Addresses remaining': '65,536 / 65,536', + }) + await expectRowVisible(table, { + name: 'secondary-v4-subnet-pool', + 'Addresses remaining': '65,536 / 65,536', + }) }) test('Subnet pool create', async ({ page }) => { @@ -55,12 +67,27 @@ test('Subnet pool detail and members', async ({ page }) => { // Check properties table await expect(page.getByText('Default IPv4 subnet pool')).toBeVisible() + await expect(page.getByText('65,008 / 65,536')).toBeVisible() // Members tab should show existing member const membersTable = page.getByRole('table') await expectRowVisible(membersTable, { Subnet: '10.128.0.0/16' }) }) +test('Addresses remaining in properties table', async ({ page }) => { + // pool with no allocations shows full capacity + await page.goto('/system/networking/subnet-pools/secondary-v4-subnet-pool') + await expect(page.getByText('65,536 / 65,536')).toBeVisible() + + // pool with allocations shows remaining / capacity + await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool') + await expect(page.getByText('65,008 / 65,536')).toBeVisible() + + // large IPv6 pool shows abbreviated bignum + await page.goto('/system/networking/subnet-pools/ipv6-subnet-pool') + await expect(page.getByText('79.2e27 / 79.2e27')).toBeVisible() +}) + test('Subnet pool add member', async ({ page }) => { await page.goto('/system/networking/subnet-pools/default-v4-subnet-pool') @@ -76,6 +103,9 @@ test('Subnet pool add member', async ({ page }) => { await expectToast(page, 'Member added') + // utilization updates: /12 adds 2^20 = 1,048,576 addresses, pushing totals over 1M + await expect(page.getByText('1.1M / 1.1M')).toBeVisible() + const table = page.getByRole('table') await expectRowVisible(table, { Subnet: '172.16.0.0/12' }) }) @@ -90,6 +120,9 @@ test('Subnet pool remove member', async ({ page }) => { // The row should be gone await expect(page.getByRole('cell', { name: '172.20.0.0/16' })).toBeHidden() + + // utilization drops to 0 / 0 after removing only member + await expect(page.getByText('0 / 0')).toBeVisible() }) test('Subnet pool linked silos', async ({ page }) => {