Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion OMICRON_VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
d7c3b00d743bcc9212b222a74ae27cc970b1ee2c
254a0c51bc0beecb79c8a9dfccce8e7bc35b5ca4
12 changes: 7 additions & 5 deletions app/api/__generated__/Api.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/api/__generated__/OMICRON_VERSION

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions app/api/__generated__/validate.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/forms/subnet-pool-member-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
Expand Down
6 changes: 2 additions & 4 deletions app/pages/system/networking/IpPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -209,9 +209,7 @@ function PoolProperties() {
</PropertiesTable.Row>
<PropertiesTable.Row label="IPs remaining">
<span>
<BigNum className="text-raise" num={utilization.remaining} />
{' / '}
<BigNum className="text-secondary" num={utilization.capacity} />
<UtilizationFraction {...utilization} />
</span>
</PropertiesTable.Row>
<PropertiesTable.DateRow date={pool.timeCreated} label="Created" />
Expand Down
5 changes: 2 additions & 3 deletions app/pages/system/networking/IpPoolsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -50,8 +50,7 @@ function UtilizationCell({ pool }: { pool: string }) {
if (!data) return <SkeletonCell />
return (
<div>
<BigNum className="text-raise" num={data.remaining} /> /{' '}
<BigNum className="text-secondary" num={data.capacity} />
<UtilizationFraction {...data} />
</div>
)
}
Expand Down
14 changes: 12 additions & 2 deletions app/pages/system/networking/SubnetPoolPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 } })

Expand All @@ -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)
Expand Down Expand Up @@ -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 (
<PropertiesTable columns={2} className="-mt-8 mb-8">
Expand All @@ -162,9 +167,13 @@ function PoolProperties() {
<PropertiesTable.Row label="IP version">
<IpVersionBadge ipVersion={pool.ipVersion} />
</PropertiesTable.Row>
{/* TODO: add utilization row once Nexus endpoint is implemented
https://github.com/oxidecomputer/omicron/issues/10109 */}
<PropertiesTable.Row label="Addresses remaining">
<span>
<UtilizationFraction {...utilization} />
</span>
</PropertiesTable.Row>
<PropertiesTable.DateRow date={pool.timeCreated} label="Created" />
<PropertiesTable.DateRow date={pool.timeModified} label="Last Modified" />
</PropertiesTable>
)
}
Expand All @@ -183,6 +192,7 @@ function MembersTable() {
const { mutateAsync: removeMember } = useApiMutation(api.systemSubnetPoolMemberRemove, {
onSuccess() {
queryClient.invalidateEndpoint('systemSubnetPoolMemberList')
queryClient.invalidateEndpoint('systemSubnetPoolUtilizationView')
},
})
const emptyState = (
Expand Down
18 changes: 16 additions & 2 deletions app/pages/system/networking/SubnetPoolsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -49,10 +51,18 @@ const EmptyState = () => (
/>
)

function UtilizationCell({ pool }: { pool: string }) {
const { data } = useQuery(q(api.systemSubnetPoolUtilizationView, { path: { pool } }))
if (!data) return <SkeletonCell />
return (
<div>
<UtilizationFraction {...data} />
</div>
)
}

const colHelper = createColumnHelper<SubnetPool>()

// 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 })),
Expand All @@ -62,6 +72,10 @@ const staticColumns = [
header: 'Version',
cell: (info) => <IpVersionBadge ipVersion={info.getValue()} />,
}),
colHelper.display({
header: 'Addresses remaining',
cell: (info) => <UtilizationCell pool={info.row.original.name} />,
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
]

Expand Down
17 changes: 17 additions & 0 deletions app/ui/lib/BigNum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,20 @@ export function BigNum({ num, className }: { num: number | bigint; className?: s

return <Tooltip content={num.toLocaleString()}>{inner}</Tooltip>
}

/** Display `remaining / capacity` with BigNum formatting. */
export function UtilizationFraction({
remaining,
capacity,
}: {
remaining: number | bigint
capacity: number | bigint
}) {
return (
<>
<BigNum className="text-raise" num={remaining} />
{' / '}
<BigNum className="text-secondary" num={capacity} />
</>
)
}
42 changes: 24 additions & 18 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 37 additions & 4 deletions test/e2e/subnet-pools.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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')

Expand All @@ -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' })
})
Expand All @@ -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 }) => {
Expand Down
Loading