From 16f6e2b65ba304826d541c85142c383e6753a9df Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 18 Mar 2024 16:11:55 -0500 Subject: [PATCH 01/18] gen API and stub ip pool utilization endpoint --- OMICRON_VERSION | 2 +- app/api/__generated__/Api.ts | 208 ++++++++++++++++++++++++++ app/api/__generated__/OMICRON_VERSION | 2 +- app/api/__generated__/msw-handlers.ts | 53 +++++++ app/api/__generated__/validate.ts | 172 +++++++++++++++++++++ mock-api/msw/handlers.ts | 10 ++ 6 files changed, 445 insertions(+), 2 deletions(-) diff --git a/OMICRON_VERSION b/OMICRON_VERSION index 1252df055d..4164cc9f1a 100644 --- a/OMICRON_VERSION +++ b/OMICRON_VERSION @@ -1 +1 @@ -1f26c66921b9215bfe11d750514939bcdc11ae12 +24763efd589a5e328043cdcfe2d0f43ecdcc986b diff --git a/app/api/__generated__/Api.ts b/app/api/__generated__/Api.ts index 4e23de5b34..a1c7e714ed 100644 --- a/app/api/__generated__/Api.ts +++ b/app/api/__generated__/Api.ts @@ -1471,6 +1471,8 @@ export type InstanceSerialConsoleData = { lastByteOffset: number } +export type IpKind = 'snat' | 'floating' | 'ephemeral' + /** * A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo */ @@ -1571,6 +1573,27 @@ export type IpPoolSiloUpdate = { */ export type IpPoolUpdate = { description?: string; name?: Name } +export type Ipv4Utilization = { + /** The number of IPv4 addresses allocated from this pool */ + allocated: number + /** The total number of IPv4 addresses in the pool, i.e., the sum of the lengths of the IPv4 ranges. Unlike IPv6 capacity, can be a 32-bit integer because there are only 2^32 IPv4 addresses. */ + capacity: number +} + +export type Ipv6Utilization = { + /** The number of IPv6 addresses allocated from this pool. A 128-bit integer string to match the capacity field. */ + allocated: string + /** The total number of IPv6 addresses in the pool, i.e., the sum of the lengths of the IPv6 ranges. An IPv6 range can contain up to 2^128 addresses, so we represent this value in JSON as a numeric string with a custom "uint128" format. */ + capacity: string +} + +export type IpPoolUtilization = { + /** Number of allocated and total available IPv4 addresses in pool */ + ipv4: Ipv4Utilization + /** Number of allocated and total available IPv6 addresses in pool */ + ipv6: Ipv6Utilization +} + /** * A range of IP ports * @@ -1709,6 +1732,37 @@ export type MeasurementResultsPage = { nextPage?: string } +/** + * The type of network interface + */ +export type NetworkInterfaceKind = + /** A vNIC attached to a guest instance */ + | { id: string; type: 'instance' } + /** A vNIC associated with an internal service */ + | { id: string; type: 'service' } + /** A vNIC associated with a probe */ + | { id: string; type: 'probe' } + +/** + * A Geneve Virtual Network Identifier + */ +export type Vni = number + +/** + * Information required to construct a virtual network interface + */ +export type NetworkInterface = { + id: string + ip: string + kind: NetworkInterfaceKind + mac: MacAddr + name: Name + primary: boolean + slot: number + subnet: IpNet + vni: Vni +} + /** * A password used to authenticate a user * @@ -1758,6 +1812,58 @@ export type Ping = { status: PingStatus } +/** + * Identity-related metadata that's included in nearly all public API objects + */ +export type Probe = { + /** human-readable free-form text about a resource */ + description: string + /** unique, immutable, system-controlled identifier for each resource */ + id: string + /** unique, mutable, user-controlled identifier for each resource */ + name: Name + sled: string + /** timestamp when this resource was created */ + timeCreated: Date + /** timestamp when this resource was last modified */ + timeModified: Date +} + +/** + * Create time parameters for probes. + */ +export type ProbeCreate = { + description: string + ipPool?: NameOrId + name: Name + sled: string +} + +export type ProbeExternalIp = { + firstPort: number + ip: string + kind: IpKind + lastPort: number +} + +export type ProbeInfo = { + externalIps: ProbeExternalIp[] + id: string + interface: NetworkInterface + name: Name + sled: string +} + +/** + * A single page of results + */ +export type ProbeInfoResultsPage = { + /** list of items on this page of results */ + items: ProbeInfo[] + /** token used to fetch the next page of results (if any) */ + nextPage?: string +} + /** * View of a Project */ @@ -3010,6 +3116,33 @@ export type SystemMetricName = */ export type NameSortMode = 'name_ascending' +export interface ProbeListQueryParams { + limit?: number + pageToken?: string + project?: NameOrId + sortBy?: NameOrIdSortMode +} + +export interface ProbeCreateQueryParams { + project: NameOrId +} + +export interface ProbeViewPathParams { + probe: NameOrId +} + +export interface ProbeViewQueryParams { + project: NameOrId +} + +export interface ProbeDeletePathParams { + probe: NameOrId +} + +export interface ProbeDeleteQueryParams { + project: NameOrId +} + export interface LoginSamlPathParams { providerName: Name siloName: Name @@ -3675,6 +3808,10 @@ export interface IpPoolSiloUnlinkPathParams { silo: NameOrId } +export interface IpPoolUtilizationViewPathParams { + pool: NameOrId +} + export interface IpPoolServiceRangeListQueryParams { limit?: number pageToken?: string @@ -3960,6 +4097,7 @@ export interface VpcDeleteQueryParams { export type ApiListMethods = Pick< InstanceType['methods'], + | 'probeList' | 'certificateList' | 'diskList' | 'diskMetricsList' @@ -4039,6 +4177,63 @@ export class Api extends HttpClient { ...params, }) }, + /** + * List instrumentation probes + */ + probeList: ( + { query = {} }: { query?: ProbeListQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/experimental/v1/probes`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Create instrumentation probe + */ + probeCreate: ( + { query, body }: { query?: ProbeCreateQueryParams; body: ProbeCreate }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/experimental/v1/probes`, + method: 'POST', + body, + query, + ...params, + }) + }, + /** + * View instrumentation probe + */ + probeView: ( + { path, query }: { path: ProbeViewPathParams; query?: ProbeViewQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/experimental/v1/probes/${path.probe}`, + method: 'GET', + query, + ...params, + }) + }, + /** + * Delete instrumentation probe + */ + probeDelete: ( + { path, query }: { path: ProbeDeletePathParams; query?: ProbeDeleteQueryParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/experimental/v1/probes/${path.probe}`, + method: 'DELETE', + query, + ...params, + }) + }, /** * Authenticate a user via SAML */ @@ -5722,6 +5917,19 @@ export class Api extends HttpClient { ...params, }) }, + /** + * Fetch IP pool utilization + */ + ipPoolUtilizationView: ( + { path }: { path: IpPoolUtilizationViewPathParams }, + params: FetchParams = {} + ) => { + return this.request({ + path: `/v1/system/ip-pools/${path.pool}/utilization`, + method: 'GET', + ...params, + }) + }, /** * Fetch Oxide service IP pool */ diff --git a/app/api/__generated__/OMICRON_VERSION b/app/api/__generated__/OMICRON_VERSION index 977aa82d85..24e680550c 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 -1f26c66921b9215bfe11d750514939bcdc11ae12 +24763efd589a5e328043cdcfe2d0f43ecdcc986b diff --git a/app/api/__generated__/msw-handlers.ts b/app/api/__generated__/msw-handlers.ts index 4c42a38f2f..76ad299f74 100644 --- a/app/api/__generated__/msw-handlers.ts +++ b/app/api/__generated__/msw-handlers.ts @@ -61,6 +61,33 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /experimental/v1/probes` */ + probeList: (params: { + query: Api.ProbeListQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `POST /experimental/v1/probes` */ + probeCreate: (params: { + query: Api.ProbeCreateQueryParams + body: Json + req: Request + cookies: Record + }) => Promisable> + /** `GET /experimental/v1/probes/:probe` */ + probeView: (params: { + path: Api.ProbeViewPathParams + query: Api.ProbeViewQueryParams + req: Request + cookies: Record + }) => Promisable> + /** `DELETE /experimental/v1/probes/:probe` */ + probeDelete: (params: { + path: Api.ProbeDeletePathParams + query: Api.ProbeDeleteQueryParams + req: Request + cookies: Record + }) => Promisable /** `POST /login/:siloName/saml/:providerName` */ loginSaml: (params: { path: Api.LoginSamlPathParams @@ -785,6 +812,12 @@ export interface MSWHandlers { req: Request cookies: Record }) => Promisable + /** `GET /v1/system/ip-pools/:pool/utilization` */ + ipPoolUtilizationView: (params: { + path: Api.IpPoolUtilizationViewPathParams + req: Request + cookies: Record + }) => Promisable> /** `GET /v1/system/ip-pools-service` */ ipPoolServiceView: (params: { req: Request @@ -1275,6 +1308,22 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { handler(handlers['deviceAuthConfirm'], null, schema.DeviceAuthVerify) ), http.post('/device/token', handler(handlers['deviceAccessToken'], null, null)), + http.get( + '/experimental/v1/probes', + handler(handlers['probeList'], schema.ProbeListParams, null) + ), + http.post( + '/experimental/v1/probes', + handler(handlers['probeCreate'], schema.ProbeCreateParams, schema.ProbeCreate) + ), + http.get( + '/experimental/v1/probes/:probe', + handler(handlers['probeView'], schema.ProbeViewParams, null) + ), + http.delete( + '/experimental/v1/probes/:probe', + handler(handlers['probeDelete'], schema.ProbeDeleteParams, null) + ), http.post( '/login/:siloName/saml/:providerName', handler(handlers['loginSaml'], schema.LoginSamlParams, null) @@ -1824,6 +1873,10 @@ export function makeHandlers(handlers: MSWHandlers): HttpHandler[] { '/v1/system/ip-pools/:pool/silos/:silo', handler(handlers['ipPoolSiloUnlink'], schema.IpPoolSiloUnlinkParams, null) ), + http.get( + '/v1/system/ip-pools/:pool/utilization', + handler(handlers['ipPoolUtilizationView'], schema.IpPoolUtilizationViewParams, null) + ), http.get( '/v1/system/ip-pools-service', handler(handlers['ipPoolServiceView'], null, null) diff --git a/app/api/__generated__/validate.ts b/app/api/__generated__/validate.ts index 312837c21f..e0debed024 100644 --- a/app/api/__generated__/validate.ts +++ b/app/api/__generated__/validate.ts @@ -1548,6 +1548,11 @@ export const InstanceSerialConsoleData = z.preprocess( z.object({ data: z.number().min(0).max(255).array(), lastByteOffset: z.number().min(0) }) ) +export const IpKind = z.preprocess( + processResponseBody, + z.enum(['snat', 'floating', 'ephemeral']) +) + /** * A collection of IP ranges. If a pool is linked to a silo, IP addresses from the pool can be allocated within that silo */ @@ -1662,6 +1667,24 @@ export const IpPoolUpdate = z.preprocess( z.object({ description: z.string().optional(), name: Name.optional() }) ) +export const Ipv4Utilization = z.preprocess( + processResponseBody, + z.object({ + allocated: z.number().min(0).max(4294967295), + capacity: z.number().min(0).max(4294967295), + }) +) + +export const Ipv6Utilization = z.preprocess( + processResponseBody, + z.object({ allocated: z.string(), capacity: z.string() }) +) + +export const IpPoolUtilization = z.preprocess( + processResponseBody, + z.object({ ipv4: Ipv4Utilization, ipv6: Ipv6Utilization }) +) + /** * A range of IP ports * @@ -1786,6 +1809,41 @@ export const MeasurementResultsPage = z.preprocess( z.object({ items: Measurement.array(), nextPage: z.string().optional() }) ) +/** + * The type of network interface + */ +export const NetworkInterfaceKind = z.preprocess( + processResponseBody, + z.union([ + z.object({ id: z.string().uuid(), type: z.enum(['instance']) }), + z.object({ id: z.string().uuid(), type: z.enum(['service']) }), + z.object({ id: z.string().uuid(), type: z.enum(['probe']) }), + ]) +) + +/** + * A Geneve Virtual Network Identifier + */ +export const Vni = z.preprocess(processResponseBody, z.number().min(0).max(4294967295)) + +/** + * Information required to construct a virtual network interface + */ +export const NetworkInterface = z.preprocess( + processResponseBody, + z.object({ + id: z.string().uuid(), + ip: z.string().ip(), + kind: NetworkInterfaceKind, + mac: MacAddr, + name: Name, + primary: SafeBoolean, + slot: z.number().min(0).max(255), + subnet: IpNet, + vni: Vni, + }) +) + /** * A password used to authenticate a user * @@ -1829,6 +1887,63 @@ export const PingStatus = z.preprocess(processResponseBody, z.enum(['ok'])) export const Ping = z.preprocess(processResponseBody, z.object({ status: PingStatus })) +/** + * Identity-related metadata that's included in nearly all public API objects + */ +export const Probe = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + id: z.string().uuid(), + name: Name, + sled: z.string().uuid(), + timeCreated: z.coerce.date(), + timeModified: z.coerce.date(), + }) +) + +/** + * Create time parameters for probes. + */ +export const ProbeCreate = z.preprocess( + processResponseBody, + z.object({ + description: z.string(), + ipPool: NameOrId.optional(), + name: Name, + sled: z.string().uuid(), + }) +) + +export const ProbeExternalIp = z.preprocess( + processResponseBody, + z.object({ + firstPort: z.number().min(0).max(65535), + ip: z.string().ip(), + kind: IpKind, + lastPort: z.number().min(0).max(65535), + }) +) + +export const ProbeInfo = z.preprocess( + processResponseBody, + z.object({ + externalIps: ProbeExternalIp.array(), + id: z.string().uuid(), + interface: NetworkInterface, + name: Name, + sled: z.string().uuid(), + }) +) + +/** + * A single page of results + */ +export const ProbeInfoResultsPage = z.preprocess( + processResponseBody, + z.object({ items: ProbeInfo.array(), nextPage: z.string().optional() }) +) + /** * View of a Project */ @@ -3002,6 +3117,53 @@ export const DeviceAccessTokenParams = z.preprocess( }) ) +export const ProbeListParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + limit: z.number().min(1).max(4294967295).optional(), + pageToken: z.string().optional(), + project: NameOrId.optional(), + sortBy: NameOrIdSortMode.optional(), + }), + }) +) + +export const ProbeCreateParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({}), + query: z.object({ + project: NameOrId, + }), + }) +) + +export const ProbeViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + probe: NameOrId, + }), + query: z.object({ + project: NameOrId, + }), + }) +) + +export const ProbeDeleteParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + probe: NameOrId, + }), + query: z.object({ + project: NameOrId, + }), + }) +) + export const LoginSamlParams = z.preprocess( processResponseBody, z.object({ @@ -4249,6 +4411,16 @@ export const IpPoolSiloUnlinkParams = z.preprocess( }) ) +export const IpPoolUtilizationViewParams = z.preprocess( + processResponseBody, + z.object({ + path: z.object({ + pool: NameOrId, + }), + query: z.object({}), + }) +) + export const IpPoolServiceViewParams = z.preprocess( processResponseBody, z.object({ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 8104244073..0a9c4897f4 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -616,6 +616,12 @@ export const handlers = makeHandlers({ return json(instance, { status: 202 }) }, ipPoolList: ({ query }) => paginated(query, db.ipPools), + ipPoolUtilizationView() { + return { + ipv4: { allocated: 0, capacity: 0 }, + ipv6: { allocated: '0', capacity: '0' }, + } + }, siloIpPoolList({ path, query }) { const pools = lookup.siloIpPools(path) return paginated(query, pools) @@ -1234,6 +1240,10 @@ export const handlers = makeHandlers({ networkingSwitchPortSettingsDelete: NotImplemented, networkingSwitchPortSettingsView: NotImplemented, networkingSwitchPortSettingsList: NotImplemented, + probeCreate: NotImplemented, + probeDelete: NotImplemented, + probeList: NotImplemented, + probeView: NotImplemented, rackView: NotImplemented, roleList: NotImplemented, roleView: NotImplemented, From 253c2d54d47d323bce772b43a3a27b89012eadb4 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 18 Mar 2024 18:03:26 -0500 Subject: [PATCH 02/18] we're having fun here --- app/pages/system/networking/IpPoolsTab.tsx | 45 ++++++++++++++++++++++ app/util/math.spec.ts | 30 ++++++++++++++- app/util/math.ts | 17 ++++++++ mock-api/msw/handlers.ts | 21 ++++++++-- tsconfig.json | 2 +- 5 files changed, 109 insertions(+), 6 deletions(-) diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index 442796d58e..b39d223f4c 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -12,6 +12,7 @@ import { Link, Outlet, useNavigate } from 'react-router-dom' import { apiQueryClient, useApiMutation, + useApiQuery, usePrefetchedApiQuery, type IpPool, } from '@oxide/api' @@ -20,11 +21,14 @@ import { Networking24Icon } from '@oxide/design-system/icons/react' import { useQuickActions } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { DateCell } from '~/table/cells/DateCell' +import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' import { linkCell } from '~/table/cells/LinkCell' import type { MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' +import { Badge } from '~/ui/lib/Badge' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { displayBigNum } from '~/util/math' import { pb } from '~/util/path-builder' const EmptyState = () => ( @@ -37,6 +41,41 @@ const EmptyState = () => ( /> ) +type IpLineProps = { + v: 4 | 6 + allocated: number | bigint + capacity: number | bigint +} + +const IpLine = ({ v, allocated, capacity }: IpLineProps) => ( +
+ + v{v} + + {capacity > 0 ? ( + <> + {displayBigNum(allocated)} / {displayBigNum(capacity)} + + ) : ( + + )} +
+) + +function IpPoolUtilizationCell({ pool }: { pool: string }) { + const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) + + if (!data) return + + const { ipv4, ipv6 } = data + return ( +
+ + +
+ ) +} + IpPoolsTab.loader = async function () { await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: 25 } }) return null @@ -99,6 +138,12 @@ export function IpPoolsTab() { } makeActions={makeActions}> pb.ipPool({ pool }))} /> + } + />
diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 564bcc35ae..08c1a13cca 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, round, splitDecimal } from './math' import { GiB } from './units' function roundTest() { @@ -59,6 +59,20 @@ describe('with default locale', () => { ])('splitDecimal %d -> %s', (input, output) => { expect(splitDecimal(input)).toEqual(output) }) + + it.each([ + [0n, '0'], + [1n, '1'], + [155n, '155'], + [999999n, '999,999'], + [9999999n, '10M'], + [492038458320n, '492B'], + [894283412938921, '894.3T'], + [1293859032098219, '1.3E15'], + [23094304823948203952304920342n, '23.1E27'], + ])('displayBigNum %d -> %s', (input, output) => { + expect(displayBigNum(input)).toEqual(output) + }) }) describe('with de-DE locale', () => { @@ -99,6 +113,20 @@ describe('with de-DE locale', () => { // rounding must work the same irrespective of locale it('round', roundTest) + it.each([ + [0n, '0'], + [1n, '1'], + [155n, '155'], + [999999n, '999,999'], + [9999999n, '10 Mio.'], // note non-breaking space + [492038458320n, '492 Mrd.'], // note non-breaking space + [894283412938921, '894,3 Bio.'], + [1293859032098219, '1,3E15'], + [23094304823948203952304920342n, '23,1E27'], + ])('displayBigNum %d -> %s', (input, output) => { + expect(displayBigNum(input)).toEqual(output) + }) + afterAll(() => { Object.defineProperty(global.navigator, 'language', { value: originalLanguage, diff --git a/app/util/math.ts b/app/util/math.ts index 2fa8c2c489..45ca7abe82 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -39,3 +39,20 @@ export function round(num: number, digits: number) { }) return Number(nf.format(num)) } + +export function displayBigNum(num: bigint | number) { + const eng = Intl.NumberFormat(navigator.language, { + notation: 'engineering', + maximumFractionDigits: 1, + }) + const compact = Intl.NumberFormat(navigator.language, { + notation: 'compact', + maximumFractionDigits: 1, + }) + + return num <= 1000000 + ? num.toLocaleString() + : num < 1e15 // this the threshold where compact stops using nice letters. see tests + ? compact.format(num) + : eng.format(num) +} diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 0a9c4897f4..6e9fbc060b 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' @@ -616,10 +617,22 @@ 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) + console.log(ipv4Ranges, ipv6Ranges) + return { - ipv4: { allocated: 0, capacity: 0 }, - ipv6: { allocated: '0', capacity: '0' }, + ipv4: { allocated: 5, capacity: 20 }, + ipv6: { + allocated: Math.floor(Math.random() * 1e8).toString(), + capacity: ( + BigInt(Math.floor(Math.random() * 1e6)) ** BigInt(Math.floor(Math.random() * 7)) + ).toString(), + }, } }, siloIpPoolList({ path, query }) { 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"] From 001662c509c66d31e21426d819b88a8fef95efad Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 18 Mar 2024 22:18:59 -0500 Subject: [PATCH 03/18] tooltips on abbreviated numbers --- app/pages/system/networking/IpPoolsTab.tsx | 32 ++++++++++--------- app/ui/lib/BigNum.tsx | 27 ++++++++++++++++ app/util/math.spec.ts | 36 +++++++++++----------- app/util/math.ts | 13 +++++--- 4 files changed, 71 insertions(+), 37 deletions(-) create mode 100644 app/ui/lib/BigNum.tsx diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index b39d223f4c..183a9c7c31 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -26,9 +26,9 @@ import { linkCell } from '~/table/cells/LinkCell' import type { MenuAction } from '~/table/columns/action-col' import { useQueryTable } from '~/table/QueryTable' import { Badge } from '~/ui/lib/Badge' +import { BigNum } from '~/ui/lib/BigNum' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' -import { displayBigNum } from '~/util/math' import { pb } from '~/util/path-builder' const EmptyState = () => ( @@ -47,20 +47,22 @@ type IpLineProps = { capacity: number | bigint } -const IpLine = ({ v, allocated, capacity }: IpLineProps) => ( -
- - v{v} - - {capacity > 0 ? ( - <> - {displayBigNum(allocated)} / {displayBigNum(capacity)} - - ) : ( - - )} -
-) +function IpLine({ v, allocated, capacity }: IpLineProps) { + return ( +
+ + v{v} + + {capacity > 0 ? ( + <> + / + + ) : ( + + )} +
+ ) +} function IpPoolUtilizationCell({ pool }: { pool: string }) { const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) diff --git a/app/ui/lib/BigNum.tsx b/app/ui/lib/BigNum.tsx new file mode 100644 index 0000000000..aa126f5784 --- /dev/null +++ b/app/ui/lib/BigNum.tsx @@ -0,0 +1,27 @@ +/* + * 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 }: { num: number | bigint }) { + const [display, abbreviated] = displayBigNum(num) + + if (!abbreviated) return display + + return ( + + {display} + + ) +} diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 08c1a13cca..dfa0b2437c 100644 --- a/app/util/math.spec.ts +++ b/app/util/math.spec.ts @@ -61,15 +61,15 @@ describe('with default locale', () => { }) it.each([ - [0n, '0'], - [1n, '1'], - [155n, '155'], - [999999n, '999,999'], - [9999999n, '10M'], - [492038458320n, '492B'], - [894283412938921, '894.3T'], - [1293859032098219, '1.3E15'], - [23094304823948203952304920342n, '23.1E27'], + [0n, ['0', false]], + [1n, ['1', false]], + [155n, ['155', false]], + [999999n, ['999,999', false]], + [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) }) @@ -114,15 +114,15 @@ describe('with de-DE locale', () => { it('round', roundTest) it.each([ - [0n, '0'], - [1n, '1'], - [155n, '155'], - [999999n, '999,999'], - [9999999n, '10 Mio.'], // note non-breaking space - [492038458320n, '492 Mrd.'], // note non-breaking space - [894283412938921, '894,3 Bio.'], - [1293859032098219, '1,3E15'], - [23094304823948203952304920342n, '23,1E27'], + [0n, ['0', false]], + [1n, ['1', false]], + [155n, ['155', false]], + [999999n, ['999,999', false]], + [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) }) diff --git a/app/util/math.ts b/app/util/math.ts index 45ca7abe82..db9d2dcbdb 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -40,7 +40,8 @@ export function round(num: number, digits: number) { return Number(nf.format(num)) } -export function displayBigNum(num: bigint | number) { +/** Boolean represents whether the number was abbreviated */ +export function displayBigNum(num: bigint | number): [string, boolean] { const eng = Intl.NumberFormat(navigator.language, { notation: 'engineering', maximumFractionDigits: 1, @@ -50,9 +51,13 @@ export function displayBigNum(num: bigint | number) { maximumFractionDigits: 1, }) - return num <= 1000000 - ? num.toLocaleString() - : num < 1e15 // this the threshold where compact stops using nice letters. see tests + const abbreviate = num > 1_000_000 + + const result = abbreviate + ? num < 1e15 // this the threshold where compact stops using nice letters. see tests ? compact.format(num) : eng.format(num) + : num.toLocaleString() + + return [result, abbreviate] } From bc547f4cdf4afd44b7403df80f0d615de3e35ed8 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Mon, 18 Mar 2024 23:46:22 -0500 Subject: [PATCH 04/18] lower case eng notation e --- app/util/math.spec.ts | 46 ++++++++++++++++++++++++++++++++++++++----- app/util/math.ts | 13 +++++++----- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index dfa0b2437c..37cb8622d0 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 { displayBigNum, round, splitDecimal } from './math' +import { displayBigNum, round, splitDecimal, toEngNotation } from './math' import { GiB } from './units' function roundTest() { @@ -68,8 +68,8 @@ describe('with default locale', () => { [9999999n, ['10M', true]], [492038458320n, ['492B', true]], [894283412938921, ['894.3T', true]], - [1293859032098219, ['1.3E15', true]], - [23094304823948203952304920342n, ['23.1E27', true]], + [1293859032098219, ['1.3e15', true]], + [23094304823948203952304920342n, ['23.1e27', true]], ])('displayBigNum %d -> %s', (input, output) => { expect(displayBigNum(input)).toEqual(output) }) @@ -121,8 +121,8 @@ describe('with de-DE locale', () => { [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]], + [1293859032098219, ['1,3e15', true]], + [23094304823948203952304920342n, ['23,1e27', true]], ])('displayBigNum %d -> %s', (input, output) => { expect(displayBigNum(input)).toEqual(output) }) @@ -134,3 +134,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 db9d2dcbdb..cfb2bcd5f0 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -40,12 +40,15 @@ 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() + /** Boolean represents whether the number was abbreviated */ export function displayBigNum(num: bigint | number): [string, boolean] { - const eng = Intl.NumberFormat(navigator.language, { - notation: 'engineering', - maximumFractionDigits: 1, - }) const compact = Intl.NumberFormat(navigator.language, { notation: 'compact', maximumFractionDigits: 1, @@ -56,7 +59,7 @@ export function displayBigNum(num: bigint | number): [string, boolean] { const result = abbreviate ? num < 1e15 // this the threshold where compact stops using nice letters. see tests ? compact.format(num) - : eng.format(num) + : toEngNotation(num) : num.toLocaleString() return [result, abbreviate] From bcdcea6602106c90167834be7fa7e3a915bd6404 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 10:39:51 -0500 Subject: [PATCH 05/18] don't bother displaying IPv6 utilization for now --- app/pages/system/networking/IpPoolsTab.tsx | 32 +++------------------- app/ui/lib/BigNum.tsx | 8 ++++-- mock-api/msw/handlers.ts | 9 +++--- 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index 183a9c7c31..55ceb8cc11 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -21,11 +21,10 @@ import { Networking24Icon } from '@oxide/design-system/icons/react' import { useQuickActions } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { DateCell } from '~/table/cells/DateCell' -import { EmptyCell, SkeletonCell } from '~/table/cells/EmptyCell' +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' -import { Badge } from '~/ui/lib/Badge' import { BigNum } from '~/ui/lib/BigNum' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -41,39 +40,16 @@ const EmptyState = () => ( /> ) -type IpLineProps = { - v: 4 | 6 - allocated: number | bigint - capacity: number | bigint -} - -function IpLine({ v, allocated, capacity }: IpLineProps) { - return ( -
- - v{v} - - {capacity > 0 ? ( - <> - / - - ) : ( - - )} -
- ) -} - function IpPoolUtilizationCell({ pool }: { pool: string }) { const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) if (!data) return - const { ipv4, ipv6 } = data + // don't bother displaying IPv6 while the API doesn't even let you add IPv6 ranges return (
- - + /{' '} +
) } diff --git a/app/ui/lib/BigNum.tsx b/app/ui/lib/BigNum.tsx index aa126f5784..b3dcdbe2aa 100644 --- a/app/ui/lib/BigNum.tsx +++ b/app/ui/lib/BigNum.tsx @@ -14,14 +14,16 @@ 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 }: { num: number | bigint }) { +export function BigNum({ num, className }: { num: number | bigint; className?: string }) { const [display, abbreviated] = displayBigNum(num) - if (!abbreviated) return display + const inner = {display} + + if (!abbreviated) return inner return ( - {display} + {inner} ) } diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 6e9fbc060b..6ef70eaeb4 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -628,10 +628,11 @@ export const handlers = makeHandlers({ return { ipv4: { allocated: 5, capacity: 20 }, ipv6: { - allocated: Math.floor(Math.random() * 1e8).toString(), - capacity: ( - BigInt(Math.floor(Math.random() * 1e6)) ** BigInt(Math.floor(Math.random() * 7)) - ).toString(), + allocated: '0', //Math.floor(Math.random() * 1e8).toString(), + capacity: '0', + // ( + // BigInt(Math.floor(Math.random() * 1e6)) ** BigInt(Math.floor(Math.random() * 7)) + // ).toString(), }, } }, From 9f64b6d9939920dc757af8685094dd3fafa38f06 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 10:40:48 -0500 Subject: [PATCH 06/18] rather elaborately get actual IP range lengths and counts --- mock-api/external-ip.ts | 2 ++ mock-api/floating-ip.ts | 2 ++ mock-api/ip-pool.ts | 9 +++++++++ mock-api/msw/handlers.ts | 36 +++++++++++++++++++++++++++++------- mock-api/msw/util.ts | 26 ++++++++++++++++++++++++++ package-lock.json | 13 +++++++++++++ package.json | 1 + 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index 99521f7298..d69a05bd46 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -27,6 +27,8 @@ 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. +// TODO: the addresses here need to come from the right pool + export const ephemeralIps: DbExternalIp[] = [ { instance_id: instances[0].id, diff --git a/mock-api/floating-ip.ts b/mock-api/floating-ip.ts index 5a0456bfbd..2fd44d28c5 100644 --- a/mock-api/floating-ip.ts +++ b/mock-api/floating-ip.ts @@ -12,6 +12,8 @@ import { instance } from './instance' import type { Json } from './json-type' import { project } from './project' +// TODO: the addresses here need to come from the right pool + // A floating IP from the default pool export const floatingIp: Json = { id: '3ca0ccb7-d66d-4fde-a871-ab9855eaea8e', diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 8da7f33af2..3d0d680e79 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -77,4 +77,13 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + { + id: '914b10e1-0452-4d87-bc9b-7b91cc7c7628', + ip_pool_id: ipPool3.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 6ef70eaeb4..92e4703c4a 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -36,6 +36,8 @@ import { getStartAndEndTime, getTimestamps, handleMetrics, + ipInAnyRange, + ipRangeLen, NotImplemented, paginated, requireFleetViewer, @@ -231,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(), @@ -623,16 +628,27 @@ export const handlers = makeHandlers({ .filter((r) => r.ip_pool_id === pool.id) .map((r) => r.range) const [ipv4Ranges, ipv6Ranges] = partitionBy(ranges, (r) => validateIp(r.first).isv4) - console.log(ipv4Ranges, ipv6Ranges) + + // 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: 5, capacity: 20 }, + 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: '0', //Math.floor(Math.random() * 1e8).toString(), - capacity: '0', - // ( - // BigInt(Math.floor(Math.random() * 1e6)) ** BigInt(Math.floor(Math.random() * 7)) - // ).toString(), + allocated: ipv6sInPool.toString(), + capacity: ipv6Ranges.reduce((acc, r) => acc + ipRangeLen(r), 0n).toString(), }, } }, @@ -723,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, @@ -738,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", From 13d5616023a6bf846da81a6245b831eb612f9843 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 12:11:39 -0500 Subject: [PATCH 07/18] put back v6 range display on the off chance there is one + for testing --- app/pages/system/networking/IpPoolsTab.tsx | 40 ++++++++++++++++++++-- mock-api/ip-pool.ts | 15 ++++++-- test/e2e/ip-pools.e2e.ts | 24 +++++++++---- 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index 55ceb8cc11..38556317e4 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -25,6 +25,7 @@ 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' +import { Badge } from '~/ui/lib/Badge' import { BigNum } from '~/ui/lib/BigNum' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' @@ -40,16 +41,49 @@ const EmptyState = () => ( /> ) +const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigint }) => ( + <> + /{' '} + + +) + function IpPoolUtilizationCell({ pool }: { pool: string }) { const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) if (!data) return - // don't bother displaying IPv6 while the API doesn't even let you add IPv6 ranges + const { ipv4 } = data + const ipv6 = { + allocated: BigInt(data.ipv6.allocated), + capacity: BigInt(data.ipv6.capacity), + } + + 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/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index 3d0d680e79..e1ff6e0300 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[] = [ { @@ -77,13 +86,15 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, + // IP pool 3 has no ranges { id: '914b10e1-0452-4d87-bc9b-7b91cc7c7628', - ip_pool_id: ipPool3.id, + ip_pool_id: ipPool4.id, range: { first: '::1', last: '::ffff:ffff:ffff:ffff', }, time_created: new Date().toISOString(), }, + // pool 4 has no ranges ] diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index f908088353..7bd2a30e9a 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: '0 / 8' }) + 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 / 0' + 'v6' + '0 / 18.4e18', + }) }) test('IP pool silo list', async ({ page }) => { @@ -179,7 +183,15 @@ 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 }) + + // 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' + '0 / 8' + 'v6' + '0 / 1', + }) }) test('remove range', async ({ page }) => { From 42582a3b54ab6ac52472c2108b429c818e1ae44b Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 12:29:43 -0500 Subject: [PATCH 08/18] draft: utilization on ip pool detail --- app/components/IpPoolUtilization.tsx | 53 ++++++++++++++++++++++ app/pages/system/networking/IpPoolPage.tsx | 12 +++++ app/pages/system/networking/IpPoolsTab.tsx | 49 ++------------------ 3 files changed, 69 insertions(+), 45 deletions(-) create mode 100644 app/components/IpPoolUtilization.tsx diff --git a/app/components/IpPoolUtilization.tsx b/app/components/IpPoolUtilization.tsx new file mode 100644 index 0000000000..935d970a60 --- /dev/null +++ b/app/components/IpPoolUtilization.tsx @@ -0,0 +1,53 @@ +/* + * 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 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({ ipv4, ipv6 }: IpPoolUtilization) { + const ipv6Parsed = { + allocated: BigInt(ipv6.allocated), + capacity: BigInt(ipv6.capacity), + } + + if (ipv6Parsed.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/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 292769dd3d..962b154ff7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -38,6 +38,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { TableControls, TableControlsButton, TableControlsText } from '~/ui/lib/Table' import { Tabs } from '~/ui/lib/Tabs' import { links } from '~/util/links' @@ -55,6 +56,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 @@ -71,11 +75,19 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { export function IpPoolPage() { const poolSelector = useIpPoolSelector() const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) + const { data: utilization } = usePrefetchedApiQuery('ipPoolUtilizationView', { + path: poolSelector, + }) return ( <> }>{pool.name} + + + {utilization.ipv4.allocated} / {utilization.ipv4.capacity} + + IP ranges diff --git a/app/pages/system/networking/IpPoolsTab.tsx b/app/pages/system/networking/IpPoolsTab.tsx index 38556317e4..3e04f2aeea 100644 --- a/app/pages/system/networking/IpPoolsTab.tsx +++ b/app/pages/system/networking/IpPoolsTab.tsx @@ -18,6 +18,7 @@ import { } 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' @@ -25,8 +26,6 @@ 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' -import { Badge } from '~/ui/lib/Badge' -import { BigNum } from '~/ui/lib/BigNum' import { buttonStyle } from '~/ui/lib/Button' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { pb } from '~/util/path-builder' @@ -41,51 +40,11 @@ const EmptyState = () => ( /> ) -const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigint }) => ( - <> - /{' '} - - -) - -function IpPoolUtilizationCell({ pool }: { pool: string }) { +function UtilizationCell({ pool }: { pool: string }) { const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } }) if (!data) return - - const { ipv4 } = data - const ipv6 = { - allocated: BigInt(data.ipv6.allocated), - capacity: BigInt(data.ipv6.capacity), - } - - 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 - - -
-
- ) + return } IpPoolsTab.loader = async function () { @@ -154,7 +113,7 @@ export function IpPoolsTab() { accessor="name" id="Utilization" header="Utilization" - cell={({ value }) => } + cell={({ value }) => } /> From 059aa5f476a7aa707de6cba308ab5be3e0b7395d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 13:44:53 -0500 Subject: [PATCH 09/18] fix e2e by invalidating ipPoolUtilizationView on add/remove range --- app/forms/ip-pool-range-add.tsx | 1 + app/pages/system/networking/IpPoolPage.tsx | 1 + test/e2e/ip-pools.e2e.ts | 7 +++++++ 3 files changed, 9 insertions(+) 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/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 962b154ff7..df3bc8ca57 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -113,6 +113,7 @@ function IpRangesTable() { const removeRange = useApiMutation('ipPoolRangeRemove', { onSuccess() { queryClient.invalidateQueries('ipPoolRangeList') + queryClient.invalidateQueries('ipPoolUtilizationView') }, }) const emptyState = ( diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 7bd2a30e9a..9e74f72698 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -214,4 +214,11 @@ test('remove range', async ({ page }) => { await expect(table.getByRole('cell', { name: '10.0.0.20' })).toBeHidden() await expect(table.getByRole('row')).toHaveCount(2) + + // 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: '0 / 5', + }) }) From d345796c00533b85a414204658ec006ab3927ac9 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 14:22:05 -0500 Subject: [PATCH 10/18] capacity bar, fix ip addresses in mock data --- app/components/CapacityBar.tsx | 18 ++++++++-------- app/components/CapacityBars.tsx | 12 +++++------ app/pages/system/networking/IpPoolPage.tsx | 24 +++++++++++++++------- mock-api/external-ip.ts | 10 ++++----- mock-api/ip-pool.ts | 4 ++-- test/e2e/ip-pools.e2e.ts | 6 +++--- 6 files changed, 42 insertions(+), 32 deletions(-) diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx index 6917136cd1..b4e0676878 100644 --- a/app/components/CapacityBar.tsx +++ b/app/components/CapacityBar.tsx @@ -13,19 +13,19 @@ export const CapacityBar = ({ title, unit, provisioned, - allocated, - allocatedLabel, + capacity, + capacityLabel, includeUnit = true, }: { icon: JSX.Element - title: 'CPU' | 'Memory' | 'Storage' - unit: 'nCPUs' | 'GiB' | 'TiB' + title: string + unit: string provisioned: number - allocated: number - allocatedLabel: string + capacity: number + capacityLabel: string includeUnit?: boolean }) => { - const percentOfAllocatedUsed = (provisioned / allocated) * 100 + const percentOfAllocatedUsed = (provisioned / capacity) * 100 const [wholeNumber, decimal] = splitDecimal(percentOfAllocatedUsed) @@ -67,9 +67,9 @@ export const CapacityBar = ({
-
{allocatedLabel}
+
{capacityLabel}
- {allocated.toLocaleString()} + {capacity.toLocaleString()} {includeUnit ? ' ' + unit : ''}
diff --git a/app/components/CapacityBars.tsx b/app/components/CapacityBars.tsx index 31c22fcad2..3fcf2324bb 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" unit="GiB" provisioned={bytesToGiB(provisioned.memory)} - allocated={bytesToGiB(allocated.memory)} - allocatedLabel={allocatedLabel} + capacity={bytesToGiB(allocated.memory)} + capacityLabel={allocatedLabel} /> } title="Storage" unit="TiB" provisioned={bytesToTiB(provisioned.storage)} - allocated={bytesToTiB(allocated.storage)} - allocatedLabel={allocatedLabel} + capacity={bytesToTiB(allocated.storage)} + capacityLabel={allocatedLabel} /> ) diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index df3bc8ca57..0cbcbf0715 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -18,8 +18,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' @@ -38,7 +43,6 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' -import { PropertiesTable } from '~/ui/lib/PropertiesTable' import { TableControls, TableControlsButton, TableControlsText } from '~/ui/lib/Table' import { Tabs } from '~/ui/lib/Tabs' import { links } from '~/util/links' @@ -83,11 +87,17 @@ export function IpPoolPage() { }>{pool.name} - - - {utilization.ipv4.allocated} / {utilization.ipv4.capacity} - - +
+ } + title="Utilization" + provisioned={utilization.ipv4.allocated} + capacity={utilization.ipv4.capacity} + capacityLabel="Capacity" + unit="IPs" + includeUnit={false} + /> +
IP ranges diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index d69a05bd46..72a3865b13 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -27,13 +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. -// TODO: the addresses here need to come from the right pool +// Note that these addreses should come from 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', }, }, @@ -41,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/ip-pool.ts b/mock-api/ip-pool.ts index e1ff6e0300..b116ba7ac2 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -63,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.4', }, time_created: new Date().toISOString(), }, diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 9e74f72698..ed2beae565 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -22,7 +22,7 @@ test('IP pool list', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows - await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '0 / 8' }) + await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '4 / 8' }) await expectRowVisible(table, { name: 'ip-pool-2', Utilization: '0 / 6' }) await expectRowVisible(table, { name: 'ip-pool-3', Utilization: '0 / 0' }) await expectRowVisible(table, { @@ -190,7 +190,7 @@ test('IP range validation and add', async ({ page }) => { await page.getByRole('link', { name: 'Networking' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - Utilization: 'v4' + '0 / 8' + 'v6' + '0 / 1', + Utilization: 'v4' + '4 / 8' + 'v6' + '0 / 1', }) }) @@ -219,6 +219,6 @@ test('remove range', async ({ page }) => { await page.getByRole('link', { name: 'Networking' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - Utilization: '0 / 5', + Utilization: '4 / 5', }) }) From 541be680b0555877b038d30b57c6a6a5e0b44f0d Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 15:21:13 -0500 Subject: [PATCH 11/18] show capacity bar on ip pool detail --- app/api/util.ts | 13 ++++++ app/components/CapacityBar.tsx | 29 +++++++----- app/components/CapacityBars.tsx | 4 +- app/pages/system/networking/IpPoolPage.tsx | 53 ++++++++++++++++------ app/util/math.spec.ts | 5 ++ app/util/math.ts | 4 +- mock-api/ip-pool.ts | 12 ++++- test/e2e/ip-pools.e2e.ts | 45 +++++++++++++++++- 8 files changed, 132 insertions(+), 33 deletions(-) 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 b4e0676878..41cc5f81ee 100644 --- a/app/components/CapacityBar.tsx +++ b/app/components/CapacityBar.tsx @@ -6,30 +6,35 @@ * Copyright Oxide Computer Company */ -import { splitDecimal } from '~/util/math' +import { displayBigNum, splitDecimal } from '~/util/math' -export const CapacityBar = ({ +export const CapacityBar = ({ icon, title, unit, provisioned, capacity, capacityLabel, + provisionedLabel = 'Provisioned', includeUnit = true, }: { icon: JSX.Element title: string unit: string - provisioned: number - capacity: number + provisioned: T + capacity: T + provisionedLabel?: string capacityLabel: string includeUnit?: boolean }) => { - const percentOfAllocatedUsed = (provisioned / capacity) * 100 + const percentage = + typeof provisioned === 'bigint' + ? (provisioned * 100n) / (capacity as bigint) // TS is being a jerk + : (provisioned * 100) / capacity - const [wholeNumber, decimal] = splitDecimal(percentOfAllocatedUsed) + const [wholeNumber, decimal] = splitDecimal(percentage) - const formattedPercentUsed = `${percentOfAllocatedUsed}%` + const formattedPercentUsed = `${percentage}%` return (
@@ -39,7 +44,7 @@ export const CapacityBar = ({ {icon}
- {title} + {title} ({unit})
@@ -60,16 +65,16 @@ export const CapacityBar = ({
-
Provisioned
+
{provisionedLabel}
- {provisioned.toLocaleString()} + {displayBigNum(provisioned)} {includeUnit ? ' ' + unit : ''}
{capacityLabel}
-
- {capacity.toLocaleString()} +
+ {displayBigNum(capacity)} {includeUnit ? ' ' + unit : ''}
diff --git a/app/components/CapacityBars.tsx b/app/components/CapacityBars.tsx index 3fcf2324bb..a15a037411 100644 --- a/app/components/CapacityBars.tsx +++ b/app/components/CapacityBars.tsx @@ -35,7 +35,7 @@ export const CapacityBars = ({ /> } - title="Memory" + title="MEMORY" unit="GiB" provisioned={bytesToGiB(provisioned.memory)} capacity={bytesToGiB(allocated.memory)} @@ -43,7 +43,7 @@ export const CapacityBars = ({ /> } - title="Storage" + title="STORAGE" unit="TiB" provisioned={bytesToTiB(provisioned.storage)} capacity={bytesToTiB(allocated.storage)} diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 0cbcbf0715..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, @@ -79,25 +80,12 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) { export function IpPoolPage() { const poolSelector = useIpPoolSelector() const { data: pool } = usePrefetchedApiQuery('ipPoolView', { path: poolSelector }) - const { data: utilization } = usePrefetchedApiQuery('ipPoolUtilizationView', { - path: poolSelector, - }) return ( <> }>{pool.name} -
- } - title="Utilization" - provisioned={utilization.ipv4.allocated} - capacity={utilization.ipv4.capacity} - capacityLabel="Capacity" - unit="IPs" - includeUnit={false} - /> -
+ IP ranges @@ -115,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 } }) diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 37cb8622d0..107c48da8e 100644 --- a/app/util/math.spec.ts +++ b/app/util/math.spec.ts @@ -56,6 +56,11 @@ describe('with default locale', () => { [1.259, ['1', '.26']], // should correctly round the decimal [-50.2, ['-50', '.2']], // should correctly not round down to -51 [1000.5, ['1,000', '.5']], // test localeString grouping + + // bigints + [0n, ['0', '']], + [1n, ['1', '']], + [49502834980392389834234891248n, ['49,502,834,980,392,389,834,234,891,248', '']], ])('splitDecimal %d -> %s', (input, output) => { expect(splitDecimal(input)).toEqual(output) }) diff --git a/app/util/math.ts b/app/util/math.ts index cfb2bcd5f0..a6f596a8e7 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -16,7 +16,9 @@ import { splitOnceBy } from './array' * minus sign, group separators [comma in en-US], and of course actual number * groups). Those will get joined and the decimal part will be the empty string. */ -export function splitDecimal(value: number): [string, string] { +export function splitDecimal(value: number | bigint): [string, string] { + if (typeof value === 'bigint') return [value.toLocaleString(), ''] + const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 }) const parts = nf.formatToParts(value) diff --git a/mock-api/ip-pool.ts b/mock-api/ip-pool.ts index b116ba7ac2..55c9beab79 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -86,7 +86,16 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, - // IP pool 3 has no ranges + // 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, @@ -96,5 +105,4 @@ export const ipPoolRanges: Json = [ }, time_created: new Date().toISOString(), }, - // pool 4 has no ranges ] diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index ed2beae565..a50fa0f853 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -27,7 +27,7 @@ test('IP pool list', async ({ page }) => { await expectRowVisible(table, { name: 'ip-pool-3', Utilization: '0 / 0' }) await expectRowVisible(table, { name: 'ip-pool-4', - Utilization: 'v4' + '0 / 0' + 'v6' + '0 / 18.4e18', + Utilization: 'v4' + '0 / 207' + 'v6' + '0 / 18.4e18', }) }) @@ -133,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)50%')).toBeVisible() + await expect(page.getByText('Allocated4')).toBeVisible() + await expect(page.getByText('Capacity8')).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' }) @@ -186,6 +193,14 @@ test('IP range validation and add', async ({ page }) => { 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)50%')).toBeVisible() + await expect(page.getByText('Allocated4')).toBeVisible() + await expect(page.getByText('Capacity8')).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, { @@ -215,6 +230,11 @@ 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)80%')).toBeVisible() + await expect(page.getByText('Allocated4')).toBeVisible() + await expect(page.getByText('Capacity5')).toBeVisible() + // go back to the pool and verify the utilization column changed await page.getByRole('link', { name: 'Networking' }).click() await expectRowVisible(table, { @@ -222,3 +242,24 @@ test('remove range', async ({ page }) => { Utilization: '4 / 5', }) }) + +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() +}) From c6797f13712885d7b096fdc6c16c3caca89c6d29 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Wed, 20 Mar 2024 15:47:42 -0500 Subject: [PATCH 12/18] use parse bigint helper in the other spot --- app/components/IpPoolUtilization.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/app/components/IpPoolUtilization.tsx b/app/components/IpPoolUtilization.tsx index 935d970a60..3a65cdf354 100644 --- a/app/components/IpPoolUtilization.tsx +++ b/app/components/IpPoolUtilization.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import type { IpPoolUtilization } from '~/api' +import { parseIpUtilization, type IpPoolUtilization } from '~/api' import { Badge } from '~/ui/lib/Badge' import { BigNum } from '~/ui/lib/BigNum' @@ -17,13 +17,10 @@ const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigi ) -export function IpUtilCell({ ipv4, ipv6 }: IpPoolUtilization) { - const ipv6Parsed = { - allocated: BigInt(ipv6.allocated), - capacity: BigInt(ipv6.capacity), - } +export function IpUtilCell(util: IpPoolUtilization) { + const { ipv4, ipv6 } = parseIpUtilization(util) - if (ipv6Parsed.capacity === 0n) { + if (ipv6.capacity === 0n) { return (
@@ -46,7 +43,7 @@ export function IpUtilCell({ ipv4, ipv6 }: IpPoolUtilization) { v6 - +
) From 87417ebf5c24c91505996ad414599fc6ac0b81e1 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 11:13:16 -0500 Subject: [PATCH 13/18] extract and test number/bignum percentage logic --- app/components/CapacityBar.tsx | 14 ++++--------- app/util/math.spec.ts | 36 +++++++++++++++++++++++++++++++++- app/util/math.ts | 18 +++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx index 41cc5f81ee..bf18774b31 100644 --- a/app/components/CapacityBar.tsx +++ b/app/components/CapacityBar.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { displayBigNum, splitDecimal } from '~/util/math' +import { displayBigNum, percentage, splitDecimal } from '~/util/math' export const CapacityBar = ({ icon, @@ -27,14 +27,8 @@ export const CapacityBar = ({ capacityLabel: string includeUnit?: boolean }) => { - const percentage = - typeof provisioned === 'bigint' - ? (provisioned * 100n) / (capacity as bigint) // TS is being a jerk - : (provisioned * 100) / capacity - - const [wholeNumber, decimal] = splitDecimal(percentage) - - const formattedPercentUsed = `${percentage}%` + const pct = percentage(provisioned, capacity) + const [wholeNumber, decimal] = splitDecimal(pct) return (
@@ -57,7 +51,7 @@ export const CapacityBar = ({
diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 107c48da8e..02aa0a9e5b 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 { displayBigNum, round, splitDecimal, toEngNotation } 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']], diff --git a/app/util/math.ts b/app/util/math.ts index a6f596a8e7..20472c8b85 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -30,6 +30,24 @@ export function splitDecimal(value: number | bigint): [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 From 43c4ab3fe27f1ccdb33a0764133820ba7b7f6097 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 11:34:12 -0500 Subject: [PATCH 14/18] use BigNum (adds tooltip) in CapacityBar instead of displayBigNum --- app/components/CapacityBar.tsx | 7 ++++--- app/ui/lib/BigNum.tsx | 6 +----- app/util/math.spec.ts | 4 ++++ app/util/math.ts | 14 ++++++++++---- 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx index bf18774b31..d7496e37de 100644 --- a/app/components/CapacityBar.tsx +++ b/app/components/CapacityBar.tsx @@ -6,7 +6,8 @@ * Copyright Oxide Computer Company */ -import { displayBigNum, percentage, splitDecimal } from '~/util/math' +import { BigNum } from '~/ui/lib/BigNum' +import { percentage, splitDecimal } from '~/util/math' export const CapacityBar = ({ icon, @@ -61,14 +62,14 @@ export const CapacityBar = ({
{provisionedLabel}
- {displayBigNum(provisioned)} + {includeUnit ? ' ' + unit : ''}
{capacityLabel}
- {displayBigNum(capacity)} + {includeUnit ? ' ' + unit : ''}
diff --git a/app/ui/lib/BigNum.tsx b/app/ui/lib/BigNum.tsx index b3dcdbe2aa..ffd2f782bd 100644 --- a/app/ui/lib/BigNum.tsx +++ b/app/ui/lib/BigNum.tsx @@ -21,9 +21,5 @@ export function BigNum({ num, className }: { num: number | bigint; className?: s if (!abbreviated) return inner - return ( - - {inner} - - ) + return {inner} } diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index 02aa0a9e5b..af73345754 100644 --- a/app/util/math.spec.ts +++ b/app/util/math.spec.ts @@ -104,6 +104,8 @@ describe('with default locale', () => { [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]], @@ -157,6 +159,8 @@ describe('with de-DE locale', () => { [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]], diff --git a/app/util/math.ts b/app/util/math.ts index 20472c8b85..2b8b1145ac 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -67,20 +67,26 @@ export const toEngNotation = (num: number | bigint, locale = navigator.language) .format(num) .toLowerCase() -/** Boolean represents whether the number was abbreviated */ +/** + * 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 abbreviate = num > 1_000_000 + const abbreviated = num >= 1_000_000 - const result = abbreviate + 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, abbreviate] + return [result, abbreviated] } From 5429a44daf59614eed0dfa64c51e2bef802db055 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 11:41:53 -0500 Subject: [PATCH 15/18] splitDecimal doesn't need to handle bigint anymore, fix de-DE comma --- app/util/math.spec.ts | 7 +------ app/util/math.ts | 4 +--- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/util/math.spec.ts b/app/util/math.spec.ts index af73345754..9d5e5ca451 100644 --- a/app/util/math.spec.ts +++ b/app/util/math.spec.ts @@ -90,11 +90,6 @@ describe('with default locale', () => { [1.259, ['1', '.26']], // should correctly round the decimal [-50.2, ['-50', '.2']], // should correctly not round down to -51 [1000.5, ['1,000', '.5']], // test localeString grouping - - // bigints - [0n, ['0', '']], - [1n, ['1', '']], - [49502834980392389834234891248n, ['49,502,834,980,392,389,834,234,891,248', '']], ])('splitDecimal %d -> %s', (input, output) => { expect(splitDecimal(input)).toEqual(output) }) @@ -160,7 +155,7 @@ describe('with de-DE locale', () => { [155n, ['155', false]], [999999n, ['999,999', false]], [1000000n, ['1 Mio.', true]], - [1234567n, ['1.2 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]], diff --git a/app/util/math.ts b/app/util/math.ts index 2b8b1145ac..90778e001b 100644 --- a/app/util/math.ts +++ b/app/util/math.ts @@ -16,9 +16,7 @@ import { splitOnceBy } from './array' * minus sign, group separators [comma in en-US], and of course actual number * groups). Those will get joined and the decimal part will be the empty string. */ -export function splitDecimal(value: number | bigint): [string, string] { - if (typeof value === 'bigint') return [value.toLocaleString(), ''] - +export function splitDecimal(value: number): [string, string] { const nf = Intl.NumberFormat(navigator.language, { maximumFractionDigits: 2 }) const parts = nf.formatToParts(value) From 7a76bb4e4d8adfc4834e24fc6a3f3e6532c25752 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 11:50:39 -0500 Subject: [PATCH 16/18] make ip-pool-1 bigger and pull floating IP addrs from it --- mock-api/external-ip.ts | 2 +- mock-api/floating-ip.ts | 6 +++--- mock-api/ip-pool.ts | 2 +- test/e2e/ip-pools.e2e.ts | 24 ++++++++++++------------ 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/mock-api/external-ip.ts b/mock-api/external-ip.ts index 72a3865b13..a90895b151 100644 --- a/mock-api/external-ip.ts +++ b/mock-api/external-ip.ts @@ -27,7 +27,7 @@ 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 addreses should come from ip-pool-1 +// Note that these addresses should come from ranges in ip-pool-1 export const ephemeralIps: DbExternalIp[] = [ { diff --git a/mock-api/floating-ip.ts b/mock-api/floating-ip.ts index 2fd44d28c5..71fc4df4f4 100644 --- a/mock-api/floating-ip.ts +++ b/mock-api/floating-ip.ts @@ -12,7 +12,7 @@ import { instance } from './instance' import type { Json } from './json-type' import { project } from './project' -// TODO: the addresses here need to come from the right pool +// Note that these addresses should come from ranges in ip-pool-1 // A floating IP from the default pool export const floatingIp: Json = { @@ -20,7 +20,7 @@ export const floatingIp: Json = { 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(), @@ -32,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 55c9beab79..074a6787d1 100644 --- a/mock-api/ip-pool.ts +++ b/mock-api/ip-pool.ts @@ -64,7 +64,7 @@ export const ipPoolRanges: Json = [ ip_pool_id: ipPool1.id, range: { first: '123.4.56.0', - last: '123.4.56.4', + last: '123.4.56.20', }, time_created: new Date().toISOString(), }, diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index a50fa0f853..93aa1fb3e2 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -22,7 +22,7 @@ test('IP pool list', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(5) // header + 4 rows - await expectRowVisible(table, { name: 'ip-pool-1', Utilization: '4 / 8' }) + 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, { @@ -136,9 +136,9 @@ test('IP range validation and add', async ({ page }) => { await page.goto('/system/networking/ip-pools/ip-pool-1') // check the utilization bar - await expect(page.getByText('IPv4(IPs)50%')).toBeVisible() - await expect(page.getByText('Allocated4')).toBeVisible() - await expect(page.getByText('Capacity8')).toBeVisible() + 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() @@ -194,9 +194,9 @@ test('IP range validation and add', async ({ page }) => { await expectRowVisible(table, { First: v6Addr, Last: v6Addr }) // now the utilization bars are split in two - await expect(page.getByText('IPv4(IPs)50%')).toBeVisible() - await expect(page.getByText('Allocated4')).toBeVisible() - await expect(page.getByText('Capacity8')).toBeVisible() + 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() @@ -205,7 +205,7 @@ test('IP range validation and add', async ({ page }) => { await page.getByRole('link', { name: 'Networking' }).click() await expectRowVisible(table, { name: 'ip-pool-1', - Utilization: 'v4' + '4 / 8' + 'v6' + '0 / 1', + Utilization: 'v4' + '6 / 24' + 'v6' + '0 / 1', }) }) @@ -231,15 +231,15 @@ test('remove range', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(2) // utilization updates - await expect(page.getByText('IPv4(IPs)80%')).toBeVisible() - await expect(page.getByText('Allocated4')).toBeVisible() - await expect(page.getByText('Capacity5')).toBeVisible() + 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: '4 / 5', + Utilization: '6 / 21', }) }) From b38dc4168ac3b4d4d3d37ce2d907045647a5869f Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 12:07:07 -0500 Subject: [PATCH 17/18] e2e test that deleting floating IP decrements utilization. caught a bug! --- app/forms/floating-ip-create.tsx | 1 + .../project/floating-ips/FloatingIpsPage.tsx | 1 + test/e2e/ip-pools.e2e.ts | 20 +++++++++++++++++++ 3 files changed, 22 insertions(+) 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/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/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index 93aa1fb3e2..10602a801e 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -243,6 +243,26 @@ test('remove range', async ({ page }) => { }) }) +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() From b774c89f908f052e921488f9553227e768b882ff Mon Sep 17 00:00:00 2001 From: David Crespo Date: Thu, 21 Mar 2024 12:12:11 -0500 Subject: [PATCH 18/18] fix failing e2e due to changed address on floating IP --- test/e2e/floating-ip-create.e2e.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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', }) })