diff --git a/app/api/util.ts b/app/api/util.ts index 1d7d8c78a8..8de7c59888 100644 --- a/app/api/util.ts +++ b/app/api/util.ts @@ -15,6 +15,7 @@ import type { DiskState, Instance, InstanceState, + IpPoolUtilization, Measurement, SiloUtilization, Sled, @@ -210,3 +211,15 @@ export function synthesizeData( return result } + +// do this by hand instead of getting elaborate in the client generator. +// see https://github.com/oxidecomputer/oxide.ts/pull/231 +export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) { + return { + ipv4, + ipv6: { + allocated: BigInt(ipv6.allocated), + capacity: BigInt(ipv6.capacity), + }, + } +} diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx index 6917136cd1..d7496e37de 100644 --- a/app/components/CapacityBar.tsx +++ b/app/components/CapacityBar.tsx @@ -6,30 +6,30 @@ * Copyright Oxide Computer Company */ -import { splitDecimal } from '~/util/math' +import { BigNum } from '~/ui/lib/BigNum' +import { percentage, splitDecimal } from '~/util/math' -export const CapacityBar = ({ +export const CapacityBar = ({ icon, title, unit, provisioned, - allocated, - allocatedLabel, + capacity, + capacityLabel, + provisionedLabel = 'Provisioned', includeUnit = true, }: { icon: JSX.Element - title: 'CPU' | 'Memory' | 'Storage' - unit: 'nCPUs' | 'GiB' | 'TiB' - provisioned: number - allocated: number - allocatedLabel: string + title: string + unit: string + provisioned: T + capacity: T + provisionedLabel?: string + capacityLabel: string includeUnit?: boolean }) => { - const percentOfAllocatedUsed = (provisioned / allocated) * 100 - - const [wholeNumber, decimal] = splitDecimal(percentOfAllocatedUsed) - - const formattedPercentUsed = `${percentOfAllocatedUsed}%` + const pct = percentage(provisioned, capacity) + const [wholeNumber, decimal] = splitDecimal(pct) return (
@@ -39,7 +39,7 @@ export const CapacityBar = ({ {icon}
- {title} + {title} ({unit})
@@ -52,7 +52,7 @@ export const CapacityBar = ({
@@ -60,16 +60,16 @@ export const CapacityBar = ({
-
Provisioned
+
{provisionedLabel}
- {provisioned.toLocaleString()} + {includeUnit ? ' ' + unit : ''}
-
{allocatedLabel}
-
- {allocated.toLocaleString()} +
{capacityLabel}
+
+ {includeUnit ? ' ' + unit : ''}
diff --git a/app/components/CapacityBars.tsx b/app/components/CapacityBars.tsx index 31c22fcad2..a15a037411 100644 --- a/app/components/CapacityBars.tsx +++ b/app/components/CapacityBars.tsx @@ -29,25 +29,25 @@ export const CapacityBars = ({ title="CPU" unit="nCPUs" provisioned={provisioned.cpus} - allocated={allocated.cpus} + capacity={allocated.cpus} includeUnit={false} - allocatedLabel={allocatedLabel} + capacityLabel={allocatedLabel} /> } - title="Memory" + title="MEMORY" unit="GiB" provisioned={bytesToGiB(provisioned.memory)} - allocated={bytesToGiB(allocated.memory)} - allocatedLabel={allocatedLabel} + capacity={bytesToGiB(allocated.memory)} + capacityLabel={allocatedLabel} /> } - title="Storage" + title="STORAGE" unit="TiB" provisioned={bytesToTiB(provisioned.storage)} - allocated={bytesToTiB(allocated.storage)} - allocatedLabel={allocatedLabel} + capacity={bytesToTiB(allocated.storage)} + capacityLabel={allocatedLabel} />
) diff --git a/app/components/IpPoolUtilization.tsx b/app/components/IpPoolUtilization.tsx new file mode 100644 index 0000000000..3a65cdf354 --- /dev/null +++ b/app/components/IpPoolUtilization.tsx @@ -0,0 +1,50 @@ +/* + * 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 { parseIpUtilization, type IpPoolUtilization } from '~/api' +import { Badge } from '~/ui/lib/Badge' +import { BigNum } from '~/ui/lib/BigNum' + +const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigint }) => ( + <> + /{' '} + + +) + +export function IpUtilCell(util: IpPoolUtilization) { + const { ipv4, ipv6 } = parseIpUtilization(util) + + if (ipv6.capacity === 0n) { + return ( +
+ +
+ ) + } + + // the API doesn't let you add IPv6 ranges, but there's a remote possibility + // a pool already exists with IPv6 ranges, so we might as well show that. also + // this is nice for e2e testing the utilization logic + return ( +
+
+ + v4 + + +
+
+ + v6 + + +
+
+ ) +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 0ad8e21545..3ef9869f2f 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -71,6 +71,7 @@ export function CreateFloatingIpSideModalForm() { const createFloatingIp = useApiMutation('floatingIpCreate', { onSuccess() { queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateQueries('ipPoolUtilizationView') addToast({ content: 'Your Floating IP has been created' }) navigate(pb.floatingIps(projectSelector)) }, diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index 332a6cf904..dcb6259188 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -68,6 +68,7 @@ export function IpPoolAddRangeSideModalForm() { onSuccess(_range) { // refetch list of projects in sidebar queryClient.invalidateQueries('ipPoolRangeList') + queryClient.invalidateQueries('ipPoolUtilizationView') addToast({ content: 'IP range added' }) onDismiss() }, diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 8ec8c6716d..4696c3f28b 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -83,6 +83,7 @@ export function FloatingIpsPage() { const deleteFloatingIp = useApiMutation('floatingIpDelete', { onSuccess() { queryClient.invalidateQueries('floatingIpList') + queryClient.invalidateQueries('ipPoolUtilizationView') addToast({ content: 'Your floating IP has been deleted' }) }, }) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 292769dd3d..7fec88b718 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -11,6 +11,7 @@ import { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, + parseIpUtilization, useApiMutation, useApiQuery, useApiQueryClient, @@ -18,8 +19,13 @@ import { type IpPoolRange, type IpPoolSiloLink, } from '@oxide/api' -import { Networking24Icon, Success12Icon } from '@oxide/design-system/icons/react' +import { + IpGlobal16Icon, + Networking24Icon, + Success12Icon, +} from '@oxide/design-system/icons/react' +import { CapacityBar } from '~/components/CapacityBar' import { ExternalLink } from '~/components/ExternalLink' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' @@ -55,6 +61,9 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { path: { pool }, query: { limit: 25 }, // match QueryTable }), + apiQueryClient.prefetchQuery('ipPoolUtilizationView', { + path: { pool }, + }), // fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId // can be mostly instant yet gracefully fall back to fetching individually @@ -76,6 +85,7 @@ export function IpPoolPage() { }>{pool.name} + IP ranges @@ -93,6 +103,43 @@ export function IpPoolPage() { ) } +function UtilizationBars() { + const { pool } = useIpPoolSelector() + const { data } = usePrefetchedApiQuery('ipPoolUtilizationView', { path: { pool } }) + const { ipv4, ipv6 } = parseIpUtilization(data) + + if (ipv4.capacity === 0 && ipv6.capacity === 0n) return null + + return ( +
+ {ipv4.capacity > 0 && ( + } + title="IPv4" + provisioned={ipv4.allocated} + capacity={ipv4.capacity} + provisionedLabel="Allocated" + capacityLabel="Capacity" + unit="IPs" + includeUnit={false} + /> + )} + {ipv6.capacity > 0 && ( + } + title="IPv6" + provisioned={ipv6.allocated} + capacity={ipv6.capacity} + provisionedLabel="Allocated" + capacityLabel="Capacity" + unit="IPs" + includeUnit={false} + /> + )} +
+ ) +} + function IpRangesTable() { const { pool } = useIpPoolSelector() const { Table, Column } = useQueryTable('ipPoolRangeList', { path: { pool } }) @@ -101,6 +148,7 @@ function IpRangesTable() { const removeRange = useApiMutation('ipPoolRangeRemove', { onSuccess() { queryClient.invalidateQueries('ipPoolRangeList') + queryClient.invalidateQueries('ipPoolUtilizationView') }, }) const emptyState = ( diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index 442796d58e..3e04f2aeea 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -12,14 +12,17 @@ import { Link, Outlet, useNavigate } from 'react-router-dom' import { apiQueryClient, useApiMutation, + useApiQuery, usePrefetchedApiQuery, type IpPool, } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { IpUtilCell } from '~/components/IpPoolUtilization' import { useQuickActions } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { DateCell } from '~/table/cells/DateCell' +import { SkeletonCell } from '~/table/cells/EmptyCell' import { linkCell } from '~/table/cells/LinkCell' import type { MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' @@ -37,6 +40,13 @@ const EmptyState = () => ( /> ) +function UtilizationCell({ pool }: { pool: string }) { + const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) + + if (!data) return + return +} + IpPoolsTab.loader = async function () { await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: 25 } }) return null @@ -99,6 +109,12 @@ export function IpPoolsTab() { } makeActions={makeActions}> pb.ipPool({ pool }))} /> + } + />
diff --git a/app/ui/lib/BigNum.tsx b/app/ui/lib/BigNum.tsx new file mode 100644 index 0000000000..ffd2f782bd --- /dev/null +++ b/app/ui/lib/BigNum.tsx @@ -0,0 +1,25 @@ +/* + * 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 { displayBigNum } from '~/util/math' + +import { Tooltip } from './Tooltip' + +/** + * Possibly abbreviate number if it's big enough, and if it is, wrap it in a + * tooltip showing the unabbreviated value. + */ +export function BigNum({ num, className }: { num: number | bigint; className?: string }) { + const [display, abbreviated] = displayBigNum(num) + + const inner = {display} + + if (!abbreviated) return inner + + return {inner} +} diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 564bcc35ae..9d5e5ca451 100644 --- a/app/util/math.spec.ts +++ b/app/util/math.spec.ts @@ -7,7 +7,7 @@ */ import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { round, splitDecimal } from './math' +import { displayBigNum, percentage, round, splitDecimal, toEngNotation } from './math' import { GiB } from './units' function roundTest() { @@ -34,6 +34,40 @@ function roundTest() { it('round', roundTest) +it.each([ + [2, 5, 40], + [1, 2, 50], + [3, 4, 75], + [7, 10, 70], + [1, 3, 33.33], + [1, 7, 14.29], + [5, 8, 62.5], + [3, 9, 33.33], + [45847389, 349848380, 13.1], + [19403, 9, 215588.89], + [1n, 2n, 50], + [3n, 4n, 75], + [7n, 10n, 70], + // want to make sure we try it with IPv6 scale numbers + [7n, 123849839483423987n, 0], + [2n ** 80n, 2n ** 81n, 50], + [2n ** 80n, (9n * 2n ** 81n) / 7n, 38.88], + [39340938283493007n, 12387938n, 317574549400.33], + // also negatives, why not + [-1, 2, -50], + [-3, 4, -75], + [-7, 10, -70], + [-1, 3, -33.33], + [-1, 7, -14.29], + [-5, 8, -62.5], + [-3, 9, -33.33], + [-1n, 2n, -50], + [-3n, 4n, -75], + [-7n, 10n, -70], +])('percentage %d / %d -> %d', (top, bottom, perc) => { + expect(percentage(top, bottom)).toBeCloseTo(perc, 2) +}) + describe('with default locale', () => { it.each([ [0.23, ['0', '.23']], @@ -59,6 +93,22 @@ describe('with default locale', () => { ])('splitDecimal %d -> %s', (input, output) => { expect(splitDecimal(input)).toEqual(output) }) + + it.each([ + [0n, ['0', false]], + [1n, ['1', false]], + [155n, ['155', false]], + [999999n, ['999,999', false]], + [1000000n, ['1M', true]], + [1234567n, ['1.2M', true]], + [9999999n, ['10M', true]], + [492038458320n, ['492B', true]], + [894283412938921, ['894.3T', true]], + [1293859032098219, ['1.3e15', true]], + [23094304823948203952304920342n, ['23.1e27', true]], + ])('displayBigNum %d -> %s', (input, output) => { + expect(displayBigNum(input)).toEqual(output) + }) }) describe('with de-DE locale', () => { @@ -99,6 +149,22 @@ describe('with de-DE locale', () => { // rounding must work the same irrespective of locale it('round', roundTest) + it.each([ + [0n, ['0', false]], + [1n, ['1', false]], + [155n, ['155', false]], + [999999n, ['999,999', false]], + [1000000n, ['1 Mio.', true]], + [1234567n, ['1,2 Mio.', true]], + [9999999n, ['10 Mio.', true]], // note non-breaking space + [492038458320n, ['492 Mrd.', true]], // note non-breaking space + [894283412938921, ['894,3 Bio.', true]], + [1293859032098219, ['1,3e15', true]], + [23094304823948203952304920342n, ['23,1e27', true]], + ])('displayBigNum %d -> %s', (input, output) => { + expect(displayBigNum(input)).toEqual(output) + }) + afterAll(() => { Object.defineProperty(global.navigator, 'language', { value: originalLanguage, @@ -106,3 +172,39 @@ describe('with de-DE locale', () => { }) }) }) + +// the point of these tests is to make sure the toLowerCase shenanigan in +// toEngNotation doesn't go horribly wrong due some obscure locale's concept of +// engineering notation + +const n = 23094304823948203952304920342n + +it.each([ + ['en-US'], + ['zh-CN'], + ['es-419'], + ['en-GB'], + ['ja-JP'], + ['en-CA'], + ['en-IN'], + ['ko-KR'], +])('toEngNotation dots %s', (locale) => { + expect(toEngNotation(n, locale)).toEqual('23.1e27') +}) + +it.each([ + ['es-ES'], + ['ru-RU'], + ['de-DE'], + ['fr-FR'], + ['pt-BR'], + ['fr-CA'], + ['it-IT'], + ['pl-PL'], + ['nl-NL'], + ['tr-TR'], + ['pt-PT'], + // ['ar-SA'], // saudi arabia, arabic script +])('toEngNotation commas %s', (locale) => { + expect(toEngNotation(n, locale)).toEqual('23,1e27') +}) diff --git a/app/util/math.ts b/app/util/math.ts index 2fa8c2c489..90778e001b 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -28,6 +28,24 @@ export function splitDecimal(value: number): [string, string] { ] } +/** + * Get slightly clever to handle both number and bigint and make sure the result + * is a number. + */ +export function percentage(top: T, bottom: T): number { + if (typeof top === 'number') return (top * 100) / bottom + + // With bigints, dividing the operands and multiplying by 100 will only give + // a bigint, and we want some decimal precision, so we do the division with + // an extra 100x in there, then convert to number, then divide by 100. Note + // that this assumes the argument to Number is small enough to convert to + // Number. This should almost certainly true -- it would be bizarre for top to + // be like 10^20 bigger than bottom. In any case, the nice thing is it seems + // JS runtimes will not overflow when Number is given a huge arg, they just + // convert to a huge number with reduced precision. + return Number(((top as bigint) * 10_000n) / (bottom as bigint)) / 100 +} + export function round(num: number, digits: number) { // unlike with splitDecimal, we hard-code en-US to ensure that Number() will // be able to parse the result @@ -39,3 +57,34 @@ export function round(num: number, digits: number) { }) return Number(nf.format(num)) } + +// a separate function because I wanted to test it with a bunch of locales +// to make sure the toLowerCase thing is ok +export const toEngNotation = (num: number | bigint, locale = navigator.language) => + Intl.NumberFormat(locale, { notation: 'engineering', maximumFractionDigits: 1 }) + .format(num) + .toLowerCase() + +/** + * Abbreviates big numbers, first in compact mode like 10.2M, then in eng + * notation above 10^15. Used internally by BigNum, which you should generally + * use instead for display as it includes a tooltip with the full value. + * + * Boolean represents whether the number was abbreviated. + */ +export function displayBigNum(num: bigint | number): [string, boolean] { + const compact = Intl.NumberFormat(navigator.language, { + notation: 'compact', + maximumFractionDigits: 1, + }) + + const abbreviated = num >= 1_000_000 + + const result = abbreviated + ? num < 1e15 // this the threshold where compact stops using nice letters. see tests + ? compact.format(num) + : toEngNotation(num) + : num.toLocaleString() + + return [result, abbreviated] +} diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index 99521f7298..a90895b151 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -27,11 +27,13 @@ type DbExternalIp = { // IPs, but we only put the ephemeral ones here. We have a separate table for // floating IPs analogous to the floating_ip view in Nexus. +// Note that these addresses should come from ranges in ip-pool-1 + export const ephemeralIps: DbExternalIp[] = [ { instance_id: instances[0].id, external_ip: { - ip: `123.4.56.0`, + ip: '123.4.56.0', kind: 'ephemeral', }, }, @@ -39,21 +41,21 @@ export const ephemeralIps: DbExternalIp[] = [ { instance_id: instances[2].id, external_ip: { - ip: `123.4.56.1`, + ip: '123.4.56.1', kind: 'ephemeral', }, }, { instance_id: instances[2].id, external_ip: { - ip: `123.4.56.2`, + ip: '123.4.56.2', kind: 'ephemeral', }, }, { instance_id: instances[2].id, external_ip: { - ip: `123.4.56.3`, + ip: '123.4.56.3', kind: 'ephemeral', }, }, diff --git a/mock-api/floating-ip.ts b/mock-api/floating-ip.ts index 5a0456bfbd..71fc4df4f4 100644 --- a/mock-api/floating-ip.ts +++ b/mock-api/floating-ip.ts @@ -12,13 +12,15 @@ import { instance } from './instance' import type { Json } from './json-type' import { project } from './project' +// Note that these addresses should come from ranges in ip-pool-1 + // A floating IP from the default pool export const floatingIp: Json = { id: '3ca0ccb7-d66d-4fde-a871-ab9855eaea8e', name: 'rootbeer-float', description: 'A classic.', instance_id: undefined, - ip: '192.168.32.1', + ip: '123.4.56.4', project_id: project.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), @@ -30,7 +32,7 @@ export const floatingIp2: Json = { name: 'cola-float', description: 'A favourite.', instance_id: instance.id, - ip: '192.168.64.64', + ip: '123.4.56.5', project_id: project.id, time_created: new Date().toISOString(), time_modified: new Date().toISOString(), diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 8da7f33af2..074a6787d1 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -34,7 +34,16 @@ const ipPool3: Json = { time_created: new Date().toISOString(), time_modified: new Date().toISOString(), } -export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3] + +const ipPool4: Json = { + id: 'a5f395a8-650e-44c9-9af8-ec21d890f61c', + name: 'ip-pool-4', + description: '', + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const ipPools: Json[] = [ipPool1, ipPool2, ipPool3, ipPool4] export const ipPoolSilos: Json[] = [ { @@ -54,8 +63,8 @@ export const ipPoolRanges: Json = [ id: 'bbfcf3f2-061e-4334-a0e7-dfcd8171f87e', ip_pool_id: ipPool1.id, range: { - first: '10.0.0.1', - last: '10.0.0.5', + first: '123.4.56.0', + last: '123.4.56.20', }, time_created: new Date().toISOString(), }, @@ -77,4 +86,23 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + // pool 3 has no ranges + { + id: '4d85c502-52cc-47f9-b525-14b64cf5f1ea', + ip_pool_id: ipPool4.id, + range: { + first: '10.0.0.50', + last: '10.0.1.0', + }, + time_created: new Date().toISOString(), + }, + { + id: '914b10e1-0452-4d87-bc9b-7b91cc7c7628', + ip_pool_id: ipPool4.id, + range: { + first: '::1', + last: '::ffff:ffff:ffff:ffff', + }, + time_created: new Date().toISOString(), + }, ] diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index e3c3cb2de3..4f60de23bd 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -20,8 +20,9 @@ import { } from '@oxide/api' import { json, makeHandlers, type Json } from '~/api/__generated__/msw-handlers' -import { sortBy } from '~/util/array' +import { partitionBy, sortBy } from '~/util/array' import { pick } from '~/util/object' +import { validateIp } from '~/util/str' import { GiB } from '~/util/units' import { genCumulativeI64Data } from '../metrics' @@ -35,6 +36,8 @@ import { getStartAndEndTime, getTimestamps, handleMetrics, + ipInAnyRange, + ipRangeLen, NotImplemented, paginated, requireFleetViewer, @@ -230,9 +233,12 @@ export const handlers = makeHandlers({ const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name }) + // TODO: when IP is specified, use ipInAnyRange to check that it is in the pool + const newFloatingIp: Json = { id: uuid(), project_id: project.id, + // TODO: use ip-num to actually get the next available IP in the pool ip: [...Array(4)].map(() => Math.floor(Math.random() * 256)).join('.'), ...body, ...getTimestamps(), @@ -616,10 +622,34 @@ export const handlers = makeHandlers({ return json(instance, { status: 202 }) }, ipPoolList: ({ query }) => paginated(query, db.ipPools), - ipPoolUtilizationView() { + ipPoolUtilizationView({ path }) { + const pool = lookup.ipPool(path) + const ranges = db.ipPoolRanges + .filter((r) => r.ip_pool_id === pool.id) + .map((r) => r.range) + const [ipv4Ranges, ipv6Ranges] = partitionBy(ranges, (r) => validateIp(r.first).isv4) + + // in the real backend there are also SNAT IPs, but we don't currently + // represent those because they are not exposed through the API (except + // through the counts) + const allIps = [ + ...db.ephemeralIps.map((eip) => eip.external_ip.ip), + ...db.floatingIps.map((fip) => fip.ip), + ] + + const ipv4sInPool = allIps.filter((ip) => ipInAnyRange(ip, ipv4Ranges)).length + const ipv6sInPool = allIps.filter((ip) => ipInAnyRange(ip, ipv6Ranges)).length + return { - ipv4: { allocated: 0, capacity: 0 }, - ipv6: { allocated: '0', capacity: '0' }, + ipv4: { + allocated: ipv4sInPool, + // ok to convert to number because we know it's small enough + capacity: Number(ipv4Ranges.reduce((acc, r) => acc + ipRangeLen(r), 0n)), + }, + ipv6: { + allocated: ipv6sInPool.toString(), + capacity: ipv6Ranges.reduce((acc, r) => acc + ipRangeLen(r), 0n).toString(), + }, } }, siloIpPoolList({ path, query }) { @@ -709,6 +739,9 @@ export const handlers = makeHandlers({ ipPoolRangeAdd({ path, body }) { const pool = lookup.ipPool(path) + // TODO: reject IPv6 ranges to match API behavior, but designate a special + // address that will let us bypass that to test IPv6 handling + const newRange: Json = { id: uuid(), ip_pool_id: pool.id, @@ -724,6 +757,9 @@ export const handlers = makeHandlers({ ipPoolRangeRemove({ path, body }) { const pool = lookup.ipPool(path) + // TODO: use ips in range helpers to refuse to remove a range with IPs + // outstanding + const idsToDelete = db.ipPoolRanges .filter( (r) => diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index d8438ea808..f3cd70db3e 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -6,6 +6,9 @@ * Copyright Oxide Computer Company */ import { differenceInSeconds, subHours } from 'date-fns' +// Works without the .js for dev server and prod build in MSW mode, but +// playwright wants the .js. No idea why, let's just add the .js. +import { IPv4, IPv6 } from 'ip-num/IPNumber.js' import { FLEET_ID, @@ -13,6 +16,7 @@ import { MIN_DISK_SIZE_GiB, totalCapacity, type DiskCreate, + type IpRange, type RoleKey, type Sled, type SystemMetricName, @@ -22,6 +26,7 @@ import { import { json, type Json } from '~/api/__generated__/msw-handlers' import { isTruthy } from '~/util/array' +import { validateIp } from '~/util/str' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' @@ -370,3 +375,24 @@ export function requireRole( // should it 404? I think the API is a mix if (!userHasRole(user, resourceType, resourceId, role)) throw 403 } + +const ipToBigInt = (ip: string): bigint => + validateIp(ip).isv4 ? new IPv4(ip).value : new IPv6(ip).value + +export const ipRangeLen = ({ first, last }: IpRange) => + ipToBigInt(last) - ipToBigInt(first) + 1n + +function ipInRange(ip: string, { first, last }: IpRange): boolean { + const ipIsV4 = validateIp(ip).isv4 + const rangeIsV4 = validateIp(first).isv4 + + // if they're not the same version then definitely false + if (ipIsV4 !== rangeIsV4) return false + + // since they're the same version we can do a version-agnostic comparison + const ipNum = ipToBigInt(ip) + return ipToBigInt(first) <= ipNum && ipNum <= ipToBigInt(last) +} + +export const ipInAnyRange = (ip: string, ranges: IpRange[]) => + ranges.some((range) => ipInRange(ip, range)) diff --git a/package-lock.json b/package-lock.json index b9cfd118a6..55c6b26fb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -86,6 +86,7 @@ "eslint-plugin-react-hooks": "^4.3.0", "husky": "^9.0.10", "identity-obj-proxy": "^3.0.0", + "ip-num": "^1.5.1", "jsdom": "^24.0.0", "lint-staged": "^15.2.2", "msw": "^2.2.0", @@ -11834,6 +11835,12 @@ "loose-envify": "^1.0.0" } }, + "node_modules/ip-num": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ip-num/-/ip-num-1.5.1.tgz", + "integrity": "sha512-QziFxgxq3mjIf5CuwlzXFYscHxgLqdEdJKRo2UJ5GurL5zrSRMzT/O+nK0ABimoFH8MWF8YwIiwECYsHc1LpUQ==", + "dev": true + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -29141,6 +29148,12 @@ "loose-envify": "^1.0.0" } }, + "ip-num": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ip-num/-/ip-num-1.5.1.tgz", + "integrity": "sha512-QziFxgxq3mjIf5CuwlzXFYscHxgLqdEdJKRo2UJ5GurL5zrSRMzT/O+nK0ABimoFH8MWF8YwIiwECYsHc1LpUQ==", + "dev": true + }, "ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/package.json b/package.json index d0d419ae30..8390e01ea2 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "eslint-plugin-react-hooks": "^4.3.0", "husky": "^9.0.10", "identity-obj-proxy": "^3.0.0", + "ip-num": "^1.5.1", "jsdom": "^24.0.0", "lint-staged": "^15.2.2", "msw": "^2.2.0", diff --git a/test/e2e/floating-ip-create.e2e.ts b/test/e2e/floating-ip-create.e2e.ts index 04d902696e..47f579a8a0 100644 --- a/test/e2e/floating-ip-create.e2e.ts +++ b/test/e2e/floating-ip-create.e2e.ts @@ -71,14 +71,14 @@ test('can create a floating IP', async ({ page }) => { test('can detach and attach a floating IP', async ({ page }) => { // check floating IP is visible on instance detail await page.goto('/projects/mock-project/instances/db1') - await expect(page.getByText('192.168.64.64')).toBeVisible() + await expect(page.getByText('123.4.56.5')).toBeVisible() // now go detach it await page.goto(floatingIpsPage) await expectRowVisible(page.getByRole('table'), { name: 'cola-float', - ip: '192.168.64.64', + ip: '123.4.56.5', 'Attached to instance': 'db1', }) await clickRowAction(page, 'cola-float', 'Detach') @@ -93,7 +93,7 @@ test('can detach and attach a floating IP', async ({ page }) => { await page.getByRole('link', { name: 'Instances' }).click() await page.getByRole('link', { name: 'db1' }).click() await expect(page.getByRole('heading', { name: 'db1' })).toBeVisible() - await expect(page.getByText('192.168.64.64')).toBeHidden() + await expect(page.getByText('123.4.56.5')).toBeHidden() // Now click back to floating IPs and reattach it to db1 await page.getByRole('link', { name: 'Floating IPs' }).click() @@ -107,7 +107,7 @@ test('can detach and attach a floating IP', async ({ page }) => { await expect(page.getByRole('dialog')).toBeHidden() await expectRowVisible(page.getByRole('table'), { name: 'cola-float', - ip: '192.168.64.64', + ip: '123.4.56.5', 'Attached to instance': 'db1', }) }) diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index f908088353..10602a801e 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -20,11 +20,15 @@ test('IP pool list', async ({ page }) => { const table = page.getByRole('table') - await expect(table.getByRole('row')).toHaveCount(4) // header + 3 rows - - await expect(page.getByRole('cell', { name: 'ip-pool-1' })).toBeVisible() - await expect(page.getByRole('cell', { name: 'ip-pool-2' })).toBeVisible() - await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() + await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows + + await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '6 / 24' }) + await expectRowVisible(table, { name: 'ip-pool-2', Utilization: '0 / 6' }) + await expectRowVisible(table, { name: 'ip-pool-3', Utilization: '0 / 0' }) + await expectRowVisible(table, { + name: 'ip-pool-4', + Utilization: 'v4' + '0 / 207' + 'v6' + '0 / 18.4e18', + }) }) test('IP pool silo list', async ({ page }) => { @@ -129,7 +133,14 @@ test('IP pool create', async ({ page }) => { }) test('IP range validation and add', async ({ page }) => { - await page.goto('/system/networking/ip-pools/ip-pool-1/ranges-add') + await page.goto('/system/networking/ip-pools/ip-pool-1') + + // check the utilization bar + await expect(page.getByText('IPv4(IPs)25%')).toBeVisible() + await expect(page.getByText('Allocated6')).toBeVisible() + await expect(page.getByText('Capacity24')).toBeVisible() + + await page.getByRole('link', { name: 'Add range' }).click() const dialog = page.getByRole('dialog', { name: 'Add IP range' }) const first = dialog.getByRole('textbox', { name: 'First' }) @@ -179,7 +190,23 @@ test('IP range validation and add', async ({ page }) => { await submit.click() await expect(dialog).toBeHidden() - await expectRowVisible(page.getByRole('table'), { First: v6Addr, Last: v6Addr }) + const table = page.getByRole('table') + await expectRowVisible(table, { First: v6Addr, Last: v6Addr }) + + // now the utilization bars are split in two + await expect(page.getByText('IPv4(IPs)25%')).toBeVisible() + await expect(page.getByText('Allocated6')).toBeVisible() + await expect(page.getByText('Capacity24')).toBeVisible() + await expect(page.getByText('IPv6(IPs)0%')).toBeVisible() + await expect(page.getByText('Allocated0')).toBeVisible() + await expect(page.getByText('Capacity1')).toBeVisible() + + // go back to the pool and verify the utilization column changed + await page.getByRole('link', { name: 'Networking' }).click() + await expectRowVisible(table, { + name: 'ip-pool-1', + Utilization: 'v4' + '6 / 24' + 'v6' + '0 / 1', + }) }) test('remove range', async ({ page }) => { @@ -202,4 +229,57 @@ test('remove range', async ({ page }) => { await expect(table.getByRole('cell', { name: '10.0.0.20' })).toBeHidden() await expect(table.getByRole('row')).toHaveCount(2) + + // utilization updates + await expect(page.getByText('IPv4(IPs)28.57%')).toBeVisible() + await expect(page.getByText('Allocated6')).toBeVisible() + await expect(page.getByText('Capacity21')).toBeVisible() + + // go back to the pool and verify the utilization column changed + await page.getByRole('link', { name: 'Networking' }).click() + await expectRowVisible(table, { + name: 'ip-pool-1', + Utilization: '6 / 21', + }) +}) + +test('deleting floating IP decrements utilization', async ({ page }) => { + await page.goto('/system/networking/ip-pools') + const table = page.getByRole('table') + await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '6 / 24' }) + + // go delete a floating IP + await page.getByLabel('Switch between system and silo').click() + await page.getByRole('menuitem', { name: 'Silo' }).click() + await page.getByRole('link', { name: 'mock-project' }).click() + await page.getByRole('link', { name: 'Floating IPs' }).click() + await clickRowAction(page, 'rootbeer-float', 'Delete') + await page.getByRole('button', { name: 'Confirm' }).click() + + // now go back and it's 5. wow + await page.getByLabel('Switch between system and silo').click() + await page.getByRole('menuitem', { name: 'System' }).click() + await page.getByRole('link', { name: 'Networking' }).click() + await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '5 / 24' }) +}) + +test('no ranges means no utilization bar', async ({ page }) => { + await page.goto('/system/networking/ip-pools/ip-pool-2') + await expect(page.getByText('IPv4(IPs)')).toBeVisible() + await expect(page.getByText('IPv6(IPs)')).toBeHidden() + + await page.goto('/system/networking/ip-pools/ip-pool-3') + await expect(page.getByText('IPv4(IPs)')).toBeHidden() + await expect(page.getByText('IPv6(IPs)')).toBeHidden() + + await page.goto('/system/networking/ip-pools/ip-pool-4') + await expect(page.getByText('IPv4(IPs)')).toBeVisible() + await expect(page.getByText('IPv6(IPs)')).toBeVisible() + + await clickRowAction(page, '10.0.0.50', 'Remove') + const confirmModal = page.getByRole('dialog', { name: 'Confirm remove range' }) + await confirmModal.getByRole('button', { name: 'Confirm' }).click() + + await expect(page.getByText('IPv4(IPs)')).toBeHidden() + await expect(page.getByText('IPv6(IPs)')).toBeVisible() }) diff --git a/tsconfig.json b/tsconfig.json index 4775ff7b0b..2ece892e2e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "es2015", + "target": "es2020", "types": ["node", "vite/client", "vitest/importMeta", "react/canary"] }, "exclude": ["tools/deno"]