diff --git a/OMICRON_VERSION b/OMICRON_VERSION
index bf2cdf03d..5d15d27be 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 b19107dde..5edbefb34 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 2b381dbd8..b8bd5a888 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 a36aad625..0c46dc8e5 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 7d15d5a4c..9327c9091 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 7c9bf6bb4..4177afbac 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 70c3be516..914a841ca 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 cc15b4fea..3403b133e 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 5a299167b..20cb35a81 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 ffd2f782b..ea78494e2 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 ff3199ca1..cbda97ede 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 e64b62916..d7a95a08e 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 }) => {