Skip to content

Commit b844a42

Browse files
authored
IP pool utilization (#2078)
* gen API and stub ip pool utilization endpoint * we're having fun here * tooltips on abbreviated numbers * lower case eng notation e * don't bother displaying IPv6 utilization for now * rather elaborately get actual IP range lengths and counts * put back v6 range display on the off chance there is one + for testing * draft: utilization on ip pool detail * fix e2e by invalidating ipPoolUtilizationView on add/remove range * capacity bar, fix ip addresses in mock data * show capacity bar on ip pool detail * use parse bigint helper in the other spot * extract and test number/bignum percentage logic * use BigNum (adds tooltip) in CapacityBar instead of displayBigNum * splitDecimal doesn't need to handle bigint anymore, fix de-DE comma * make ip-pool-1 bigger and pull floating IP addrs from it * e2e test that deleting floating IP decrements utilization. caught a bug! * fix failing e2e due to changed address on floating IP
1 parent 47835ee commit b844a42

22 files changed

+550
-56
lines changed

app/api/util.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
DiskState,
1616
Instance,
1717
InstanceState,
18+
IpPoolUtilization,
1819
Measurement,
1920
SiloUtilization,
2021
Sled,
@@ -210,3 +211,15 @@ export function synthesizeData(
210211

211212
return result
212213
}
214+
215+
// do this by hand instead of getting elaborate in the client generator.
216+
// see https://github.com/oxidecomputer/oxide.ts/pull/231
217+
export function parseIpUtilization({ ipv4, ipv6 }: IpPoolUtilization) {
218+
return {
219+
ipv4,
220+
ipv6: {
221+
allocated: BigInt(ipv6.allocated),
222+
capacity: BigInt(ipv6.capacity),
223+
},
224+
}
225+
}

app/components/CapacityBar.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,30 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
import { splitDecimal } from '~/util/math'
9+
import { BigNum } from '~/ui/lib/BigNum'
10+
import { percentage, splitDecimal } from '~/util/math'
1011

11-
export const CapacityBar = ({
12+
export const CapacityBar = <T extends number | bigint>({
1213
icon,
1314
title,
1415
unit,
1516
provisioned,
16-
allocated,
17-
allocatedLabel,
17+
capacity,
18+
capacityLabel,
19+
provisionedLabel = 'Provisioned',
1820
includeUnit = true,
1921
}: {
2022
icon: JSX.Element
21-
title: 'CPU' | 'Memory' | 'Storage'
22-
unit: 'nCPUs' | 'GiB' | 'TiB'
23-
provisioned: number
24-
allocated: number
25-
allocatedLabel: string
23+
title: string
24+
unit: string
25+
provisioned: T
26+
capacity: T
27+
provisionedLabel?: string
28+
capacityLabel: string
2629
includeUnit?: boolean
2730
}) => {
28-
const percentOfAllocatedUsed = (provisioned / allocated) * 100
29-
30-
const [wholeNumber, decimal] = splitDecimal(percentOfAllocatedUsed)
31-
32-
const formattedPercentUsed = `${percentOfAllocatedUsed}%`
31+
const pct = percentage(provisioned, capacity)
32+
const [wholeNumber, decimal] = splitDecimal(pct)
3333

3434
return (
3535
<div className="w-full min-w-min rounded-lg border border-default">
@@ -39,7 +39,7 @@ export const CapacityBar = ({
3939
{icon}
4040
</div>
4141
<div className="flex flex-grow items-start">
42-
<span className="text-mono-sm text-secondary">{title}</span>
42+
<span className="!normal-case text-mono-sm text-secondary">{title}</span>
4343
<span className="ml-1 !normal-case text-mono-sm text-quaternary">({unit})</span>
4444
</div>
4545
<div className="flex -translate-y-0.5 items-baseline">
@@ -52,24 +52,24 @@ export const CapacityBar = ({
5252
<div className="flex w-full gap-0.5">
5353
<div
5454
className="h-3 rounded-l border bg-accent-secondary border-accent-secondary"
55-
style={{ width: formattedPercentUsed }}
55+
style={{ width: `${pct.toFixed(2)}%` }}
5656
></div>
5757
<div className="h-3 grow rounded-r border bg-info-secondary border-info-secondary"></div>
5858
</div>
5959
</div>
6060
<div>
6161
<div className="flex justify-between border-t border-secondary">
6262
<div className="p-3 text-mono-sm">
63-
<div className="text-quaternary">Provisioned</div>
63+
<div className="text-quaternary">{provisionedLabel}</div>
6464
<div className="text-secondary">
65-
{provisioned.toLocaleString()}
65+
<BigNum num={provisioned} />
6666
<span className="normal-case">{includeUnit ? ' ' + unit : ''}</span>
6767
</div>
6868
</div>
6969
<div className="p-3 text-mono-sm">
70-
<div className="text-quaternary">{allocatedLabel}</div>
71-
<div className="text-secondary">
72-
{allocated.toLocaleString()}
70+
<div className="text-quaternary">{capacityLabel}</div>
71+
<div className="!normal-case text-secondary">
72+
<BigNum num={capacity} />
7373
<span className="normal-case">{includeUnit ? ' ' + unit : ''}</span>
7474
</div>
7575
</div>

app/components/CapacityBars.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,25 +29,25 @@ export const CapacityBars = ({
2929
title="CPU"
3030
unit="nCPUs"
3131
provisioned={provisioned.cpus}
32-
allocated={allocated.cpus}
32+
capacity={allocated.cpus}
3333
includeUnit={false}
34-
allocatedLabel={allocatedLabel}
34+
capacityLabel={allocatedLabel}
3535
/>
3636
<CapacityBar
3737
icon={<Ram16Icon />}
38-
title="Memory"
38+
title="MEMORY"
3939
unit="GiB"
4040
provisioned={bytesToGiB(provisioned.memory)}
41-
allocated={bytesToGiB(allocated.memory)}
42-
allocatedLabel={allocatedLabel}
41+
capacity={bytesToGiB(allocated.memory)}
42+
capacityLabel={allocatedLabel}
4343
/>
4444
<CapacityBar
4545
icon={<Ssd16Icon />}
46-
title="Storage"
46+
title="STORAGE"
4747
unit="TiB"
4848
provisioned={bytesToTiB(provisioned.storage)}
49-
allocated={bytesToTiB(allocated.storage)}
50-
allocatedLabel={allocatedLabel}
49+
capacity={bytesToTiB(allocated.storage)}
50+
capacityLabel={allocatedLabel}
5151
/>
5252
</div>
5353
)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { parseIpUtilization, type IpPoolUtilization } from '~/api'
10+
import { Badge } from '~/ui/lib/Badge'
11+
import { BigNum } from '~/ui/lib/BigNum'
12+
13+
const IpUtilFrac = (props: { allocated: number | bigint; capacity: number | bigint }) => (
14+
<>
15+
<BigNum className="text-default" num={props.allocated} /> /{' '}
16+
<BigNum className="text-tertiary" num={props.capacity} />
17+
</>
18+
)
19+
20+
export function IpUtilCell(util: IpPoolUtilization) {
21+
const { ipv4, ipv6 } = parseIpUtilization(util)
22+
23+
if (ipv6.capacity === 0n) {
24+
return (
25+
<div className="space-y-1">
26+
<IpUtilFrac {...ipv4} />
27+
</div>
28+
)
29+
}
30+
31+
// the API doesn't let you add IPv6 ranges, but there's a remote possibility
32+
// a pool already exists with IPv6 ranges, so we might as well show that. also
33+
// this is nice for e2e testing the utilization logic
34+
return (
35+
<div className="space-y-1">
36+
<div>
37+
<Badge color="neutral" className="mr-2 !normal-case">
38+
v4
39+
</Badge>
40+
<IpUtilFrac {...ipv4} />
41+
</div>
42+
<div>
43+
<Badge color="neutral" className="mr-2 !normal-case">
44+
v6
45+
</Badge>
46+
<IpUtilFrac {...ipv6} />
47+
</div>
48+
</div>
49+
)
50+
}

app/forms/floating-ip-create.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function CreateFloatingIpSideModalForm() {
7171
const createFloatingIp = useApiMutation('floatingIpCreate', {
7272
onSuccess() {
7373
queryClient.invalidateQueries('floatingIpList')
74+
queryClient.invalidateQueries('ipPoolUtilizationView')
7475
addToast({ content: 'Your Floating IP has been created' })
7576
navigate(pb.floatingIps(projectSelector))
7677
},

app/forms/ip-pool-range-add.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export function IpPoolAddRangeSideModalForm() {
6868
onSuccess(_range) {
6969
// refetch list of projects in sidebar
7070
queryClient.invalidateQueries('ipPoolRangeList')
71+
queryClient.invalidateQueries('ipPoolUtilizationView')
7172
addToast({ content: 'IP range added' })
7273
onDismiss()
7374
},

app/pages/project/floating-ips/FloatingIpsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export function FloatingIpsPage() {
8383
const deleteFloatingIp = useApiMutation('floatingIpDelete', {
8484
onSuccess() {
8585
queryClient.invalidateQueries('floatingIpList')
86+
queryClient.invalidateQueries('ipPoolUtilizationView')
8687
addToast({ content: 'Your floating IP has been deleted' })
8788
},
8889
})

app/pages/system/networking/IpPoolPage.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,21 @@ import { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom'
1111

1212
import {
1313
apiQueryClient,
14+
parseIpUtilization,
1415
useApiMutation,
1516
useApiQuery,
1617
useApiQueryClient,
1718
usePrefetchedApiQuery,
1819
type IpPoolRange,
1920
type IpPoolSiloLink,
2021
} from '@oxide/api'
21-
import { Networking24Icon, Success12Icon } from '@oxide/design-system/icons/react'
22+
import {
23+
IpGlobal16Icon,
24+
Networking24Icon,
25+
Success12Icon,
26+
} from '@oxide/design-system/icons/react'
2227

28+
import { CapacityBar } from '~/components/CapacityBar'
2329
import { ExternalLink } from '~/components/ExternalLink'
2430
import { ListboxField } from '~/components/form/fields/ListboxField'
2531
import { HL } from '~/components/HL'
@@ -55,6 +61,9 @@ IpPoolPage.loader = async function ({ params }: LoaderFunctionArgs) {
5561
path: { pool },
5662
query: { limit: 25 }, // match QueryTable
5763
}),
64+
apiQueryClient.prefetchQuery('ipPoolUtilizationView', {
65+
path: { pool },
66+
}),
5867

5968
// fetch silos and preload into RQ cache so fetches by ID in SiloNameFromId
6069
// can be mostly instant yet gracefully fall back to fetching individually
@@ -76,6 +85,7 @@ export function IpPoolPage() {
7685
<PageHeader>
7786
<PageTitle icon={<Networking24Icon />}>{pool.name}</PageTitle>
7887
</PageHeader>
88+
<UtilizationBars />
7989
<QueryParamTabs className="full-width" defaultValue="ranges">
8090
<Tabs.List>
8191
<Tabs.Trigger value="ranges">IP ranges</Tabs.Trigger>
@@ -93,6 +103,43 @@ export function IpPoolPage() {
93103
)
94104
}
95105

106+
function UtilizationBars() {
107+
const { pool } = useIpPoolSelector()
108+
const { data } = usePrefetchedApiQuery('ipPoolUtilizationView', { path: { pool } })
109+
const { ipv4, ipv6 } = parseIpUtilization(data)
110+
111+
if (ipv4.capacity === 0 && ipv6.capacity === 0n) return null
112+
113+
return (
114+
<div className="-mt-8 mb-8 flex min-w-min flex-col gap-3 lg+:flex-row">
115+
{ipv4.capacity > 0 && (
116+
<CapacityBar
117+
icon={<IpGlobal16Icon />}
118+
title="IPv4"
119+
provisioned={ipv4.allocated}
120+
capacity={ipv4.capacity}
121+
provisionedLabel="Allocated"
122+
capacityLabel="Capacity"
123+
unit="IPs"
124+
includeUnit={false}
125+
/>
126+
)}
127+
{ipv6.capacity > 0 && (
128+
<CapacityBar
129+
icon={<IpGlobal16Icon />}
130+
title="IPv6"
131+
provisioned={ipv6.allocated}
132+
capacity={ipv6.capacity}
133+
provisionedLabel="Allocated"
134+
capacityLabel="Capacity"
135+
unit="IPs"
136+
includeUnit={false}
137+
/>
138+
)}
139+
</div>
140+
)
141+
}
142+
96143
function IpRangesTable() {
97144
const { pool } = useIpPoolSelector()
98145
const { Table, Column } = useQueryTable('ipPoolRangeList', { path: { pool } })
@@ -101,6 +148,7 @@ function IpRangesTable() {
101148
const removeRange = useApiMutation('ipPoolRangeRemove', {
102149
onSuccess() {
103150
queryClient.invalidateQueries('ipPoolRangeList')
151+
queryClient.invalidateQueries('ipPoolUtilizationView')
104152
},
105153
})
106154
const emptyState = (

app/pages/system/networking/IpPoolsTab.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,17 @@ import { Link, Outlet, useNavigate } from 'react-router-dom'
1212
import {
1313
apiQueryClient,
1414
useApiMutation,
15+
useApiQuery,
1516
usePrefetchedApiQuery,
1617
type IpPool,
1718
} from '@oxide/api'
1819
import { Networking24Icon } from '@oxide/design-system/icons/react'
1920

21+
import { IpUtilCell } from '~/components/IpPoolUtilization'
2022
import { useQuickActions } from '~/hooks'
2123
import { confirmDelete } from '~/stores/confirm-delete'
2224
import { DateCell } from '~/table/cells/DateCell'
25+
import { SkeletonCell } from '~/table/cells/EmptyCell'
2326
import { linkCell } from '~/table/cells/LinkCell'
2427
import type { MenuAction } from '~/table/columns/action-col'
2528
import { useQueryTable } from '~/table/QueryTable'
@@ -37,6 +40,13 @@ const EmptyState = () => (
3740
/>
3841
)
3942

43+
function UtilizationCell({ pool }: { pool: string }) {
44+
const { data } = useApiQuery('ipPoolUtilizationView', { path: { pool } })
45+
46+
if (!data) return <SkeletonCell />
47+
return <IpUtilCell {...data} />
48+
}
49+
4050
IpPoolsTab.loader = async function () {
4151
await apiQueryClient.prefetchQuery('ipPoolList', { query: { limit: 25 } })
4252
return null
@@ -99,6 +109,12 @@ export function IpPoolsTab() {
99109
<Table emptyState={<EmptyState />} makeActions={makeActions}>
100110
<Column accessor="name" cell={linkCell((pool) => pb.ipPool({ pool }))} />
101111
<Column accessor="description" />
112+
<Column
113+
accessor="name"
114+
id="Utilization"
115+
header="Utilization"
116+
cell={({ value }) => <UtilizationCell pool={value} />}
117+
/>
102118
<Column accessor="timeCreated" header="Created" cell={DateCell} />
103119
</Table>
104120
<Outlet />

app/ui/lib/BigNum.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import { displayBigNum } from '~/util/math'
10+
11+
import { Tooltip } from './Tooltip'
12+
13+
/**
14+
* Possibly abbreviate number if it's big enough, and if it is, wrap it in a
15+
* tooltip showing the unabbreviated value.
16+
*/
17+
export function BigNum({ num, className }: { num: number | bigint; className?: string }) {
18+
const [display, abbreviated] = displayBigNum(num)
19+
20+
const inner = <span className={className}>{display}</span>
21+
22+
if (!abbreviated) return inner
23+
24+
return <Tooltip content={num.toLocaleString()}>{inner}</Tooltip>
25+
}

0 commit comments

Comments
 (0)