Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dynamic units #2494

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
24 changes: 17 additions & 7 deletions app/components/CapacityBars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import type { VirtualResourceCounts } from '@oxide/api'
import { Cpu16Icon, Ram16Icon, Ssd16Icon } from '@oxide/design-system/icons/react'

import { bytesToGiB, bytesToTiB } from '~/util/units'
import { formatBytesAs, getUnit } from '~/util/units'

import { CapacityBar } from './CapacityBar'

Expand All @@ -22,6 +22,16 @@ export const CapacityBars = ({
provisioned: VirtualResourceCounts
allocatedLabel: string
}) => {
// These will most likely be GiB, but calculating dynamically to handle larger configurations in the future
const memoryUnit = getUnit(Math.max(provisioned.memory, allocated.memory))
const provisionedMemory = formatBytesAs(provisioned.memory, memoryUnit)
const allocatedMemory = formatBytesAs(allocated.memory, memoryUnit)

// These will most likely be TiB, but calculating dynamically for the same reason as above
const storageUnit = getUnit(Math.max(provisioned.storage, allocated.storage))
const provisionedStorage = formatBytesAs(provisioned.storage, storageUnit)
const allocatedStorage = formatBytesAs(allocated.storage, storageUnit)

return (
<div className="mb-12 flex min-w-min flex-col gap-3 lg+:flex-row">
<CapacityBar
Expand All @@ -36,17 +46,17 @@ export const CapacityBars = ({
<CapacityBar
icon={<Ram16Icon />}
title="MEMORY"
unit="GiB"
provisioned={bytesToGiB(provisioned.memory)}
capacity={bytesToGiB(allocated.memory)}
unit={memoryUnit}
provisioned={provisionedMemory}
capacity={allocatedMemory}
capacityLabel={allocatedLabel}
/>
<CapacityBar
icon={<Ssd16Icon />}
title="STORAGE"
unit="TiB"
provisioned={bytesToTiB(provisioned.storage)}
capacity={bytesToTiB(allocated.storage)}
unit={storageUnit}
provisioned={provisionedStorage}
capacity={allocatedStorage}
capacityLabel={allocatedLabel}
/>
</div>
Expand Down
86 changes: 50 additions & 36 deletions app/pages/system/UtilizationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
FLEET_ID,
totalUtilization,
usePrefetchedApiQuery,
type SiloUtilization,
} from '@oxide/api'
import { Metrics16Icon, Metrics24Icon } from '@oxide/design-system/icons/react'

Expand All @@ -33,7 +34,13 @@ import { Tabs } from '~/ui/lib/Tabs'
import { docLinks } from '~/util/links'
import { round } from '~/util/math'
import { pb } from '~/util/path-builder'
import { bytesToGiB, bytesToTiB } from '~/util/units'
import {
bytesToGiB,
bytesToTiB,
formatBytesAs,
getUnit,
type BinaryUnit,
} from '~/util/units'

export async function loader() {
await Promise.all([
Expand Down Expand Up @@ -196,38 +203,23 @@ function UsageTab() {
/>
</Table.Cell>
<Table.Cell width="14%" height="large">
<UsageCell
provisioned={bytesToGiB(silo.provisioned.memory)}
allocated={bytesToGiB(silo.allocated.memory)}
unit="GiB"
/>
<SiloCell cellType="usage" silo={silo} resource="memory" />
</Table.Cell>
<Table.Cell width="14%" height="large">
<UsageCell
provisioned={bytesToTiB(silo.provisioned.storage)}
allocated={bytesToTiB(silo.allocated.storage)}
unit="TiB"
/>
<SiloCell cellType="usage" silo={silo} resource="storage" />
</Table.Cell>
<Table.Cell width="14%" className="relative" height="large">
<AvailableCell
provisioned={silo.provisioned.cpus}
allocated={silo.allocated.cpus}
usagePercent={(silo.provisioned.cpus / silo.allocated.cpus) * 100}
/>
</Table.Cell>
<Table.Cell width="14%" className="relative" height="large">
<AvailableCell
provisioned={bytesToGiB(silo.provisioned.memory)}
allocated={bytesToGiB(silo.allocated.memory)}
unit="GiB"
/>
<SiloCell cellType="available" silo={silo} resource="memory" />
</Table.Cell>
<Table.Cell width="14%" className="relative" height="large">
<AvailableCell
provisioned={bytesToTiB(silo.provisioned.storage)}
allocated={bytesToTiB(silo.allocated.storage)}
unit="TiB"
/>
<SiloCell cellType="available" silo={silo} resource="storage" />
</Table.Cell>
<Table.Cell className="action-col w-10 children:p-0" height="large">
<RowActions id={silo.siloId} copyIdLabel="Copy silo ID" />
Expand All @@ -239,15 +231,9 @@ function UsageTab() {
)
}

const UsageCell = ({
provisioned,
allocated,
unit,
}: {
provisioned: number
allocated: number
unit?: string
}) => (
type CellProps = { provisioned: number; allocated: number; unit?: BinaryUnit }

const UsageCell = ({ provisioned, allocated, unit }: CellProps) => (
<div className="flex flex-col text-secondary">
<div>
<span className="text-raise">{provisioned}</span> /
Expand All @@ -262,12 +248,8 @@ const AvailableCell = ({
provisioned,
allocated,
unit,
}: {
provisioned: number
allocated: number
unit?: string
}) => {
const usagePercent = (provisioned / allocated) * 100
usagePercent,
}: CellProps & { usagePercent: number }) => {
return (
<div className="flex w-full items-center justify-between">
<div>
Expand All @@ -283,3 +265,35 @@ const AvailableCell = ({
</div>
)
}

type SiloCellProps = {
silo: SiloUtilization
// CPUs have simpler data representations, so we don't use SiloCell for them
resource: 'memory' | 'storage'
cellType: 'usage' | 'available'
}

/** A wrapper around the UsageCell and AvailableCell components,
for rendering the silo's memory and storage resources */
const SiloCell = ({ silo, resource, cellType }: SiloCellProps) => {
// Get the raw values from the silo object
const provisionedRaw = silo.provisioned[resource]
const allocatedRaw = silo.allocated[resource]
// Use those to get the standardized unit
const unit = getUnit(Math.max(provisionedRaw, allocatedRaw))
const provisioned = formatBytesAs(provisionedRaw, unit)
const allocated = formatBytesAs(allocatedRaw, unit)
if (cellType === 'usage')
return <UsageCell provisioned={provisioned} allocated={allocated} unit={unit} />

// Use the original values to calculate the usage percentage
const usagePercent = (provisionedRaw / allocatedRaw) * 100
return (
<AvailableCell
provisioned={provisioned}
allocated={allocated}
unit={unit}
usagePercent={usagePercent}
/>
)
}
17 changes: 12 additions & 5 deletions app/pages/system/silos/SiloQuotasTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { Message } from '~/ui/lib/Message'
import { Table } from '~/ui/lib/Table'
import { classed } from '~/util/classed'
import { links } from '~/util/links'
import { bytesToGiB, GiB } from '~/util/units'
import { bytesToGiB, formatBytesAs, getUnit, GiB } from '~/util/units'

const Unit = classed.span`ml-1 text-secondary`

Expand All @@ -35,6 +35,13 @@ export function SiloQuotasTab() {
})

const { allocated: quotas, provisioned } = utilization
const memoryUnits = getUnit(Math.max(provisioned.memory, quotas.memory))
const provisionedMemory = formatBytesAs(provisioned.memory, memoryUnits)
const quotasMemory = formatBytesAs(quotas.memory, memoryUnits)

const storageUnits = getUnit(Math.max(provisioned.storage, quotas.storage))
const provisionedStorage = formatBytesAs(provisioned.storage, storageUnits)
const quotasStorage = formatBytesAs(quotas.storage, storageUnits)

const [editing, setEditing] = useState(false)

Expand All @@ -61,19 +68,19 @@ export function SiloQuotasTab() {
<Table.Row>
<Table.Cell>Memory</Table.Cell>
<Table.Cell>
{bytesToGiB(provisioned.memory)} <Unit>GiB</Unit>
{provisionedMemory} <Unit>{memoryUnits}</Unit>
</Table.Cell>
<Table.Cell>
{bytesToGiB(quotas.memory)} <Unit>GiB</Unit>
{quotasMemory} <Unit>{memoryUnits}</Unit>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Storage</Table.Cell>
<Table.Cell>
{bytesToGiB(provisioned.storage)} <Unit>GiB</Unit>
{provisionedStorage} <Unit>{storageUnits}</Unit>
</Table.Cell>
<Table.Cell>
{bytesToGiB(quotas.storage)} <Unit>GiB</Unit>
{quotasStorage} <Unit>{storageUnits}</Unit>
</Table.Cell>
</Table.Row>
</Table.Body>
Expand Down
57 changes: 57 additions & 0 deletions app/util/units.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* 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 { expect, it } from 'vitest'

import { formatBytes } from './units'

function formatBytesTest() {
// the basics
expect(formatBytes(1024)).toEqual({ number: 1, unit: 'KiB' })
expect(formatBytes(1048576)).toEqual({ number: 1, unit: 'MiB' })
expect(formatBytes(1073741824)).toEqual({ number: 1, unit: 'GiB' })
expect(formatBytes(1099511627776)).toEqual({ number: 1, unit: 'TiB' })

// double those
expect(formatBytes(2048)).toEqual({ number: 2, unit: 'KiB' })
expect(formatBytes(2097152)).toEqual({ number: 2, unit: 'MiB' })
expect(formatBytes(2147483648)).toEqual({ number: 2, unit: 'GiB' })
expect(formatBytes(2199023255552)).toEqual({ number: 2, unit: 'TiB' })

// just 1.5 now
expect(formatBytes(1536)).toEqual({ number: 1.5, unit: 'KiB' })
expect(formatBytes(1572864)).toEqual({ number: 1.5, unit: 'MiB' })
expect(formatBytes(1610612736)).toEqual({ number: 1.5, unit: 'GiB' })
expect(formatBytes(1649267441664)).toEqual({ number: 1.5, unit: 'TiB' })

// let's do two decimal places (1.75)
expect(formatBytes(1792)).toEqual({ number: 1.75, unit: 'KiB' })
expect(formatBytes(1835008)).toEqual({ number: 1.75, unit: 'MiB' })
expect(formatBytes(1879048192)).toEqual({ number: 1.75, unit: 'GiB' })
expect(formatBytes(1924145348608)).toEqual({ number: 1.75, unit: 'TiB' })

// and three decimal places (1.755)
expect(formatBytes(1797.12, 3)).toEqual({ number: 1.755, unit: 'KiB' })
expect(formatBytes(1840250.88, 3)).toEqual({ number: 1.755, unit: 'MiB' })
expect(formatBytes(1884416901.12, 3)).toEqual({ number: 1.755, unit: 'GiB' })
expect(formatBytes(1929642906746.88, 3)).toEqual({
number: 1.755,
unit: 'TiB',
})

// but if we only want two decimal places, it should round appropriately
// note the missing second argument, so we'll used the default decimal value, 2
expect(formatBytes(1797.12)).toEqual({ number: 1.76, unit: 'KiB' })
expect(formatBytes(1840250.88)).toEqual({ number: 1.76, unit: 'MiB' })
expect(formatBytes(1884416901.12)).toEqual({ number: 1.76, unit: 'GiB' })
expect(formatBytes(1929642906746.88)).toEqual({
number: 1.76,
unit: 'TiB',
})
}

it('rounds to a rational number', formatBytesTest)
28 changes: 28 additions & 0 deletions app/util/units.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,40 @@
*
* Copyright Oxide Computer Company
*/

import { round } from './math'

// We only need to support up to TiB for now, but we can add more if needed
export type BinaryUnit = 'KiB' | 'MiB' | 'GiB' | 'TiB' // | 'PiB' | 'EiB' | 'ZiB' | 'YiB'

export const KiB = 1024
export const MiB = 1024 * KiB
export const GiB = 1024 * MiB
export const TiB = 1024 * GiB

export const bytesToKiB = (b: number, digits = 2) => round(b / KiB, digits)
export const bytesToMiB = (b: number, digits = 2) => round(b / MiB, digits)
export const bytesToGiB = (b: number, digits = 2) => round(b / GiB, digits)
export const bytesToTiB = (b: number, digits = 2) => round(b / TiB, digits)

type FormattedBytes = { number: number; unit: BinaryUnit }

const unitSize: Record<BinaryUnit, number> = { KiB, MiB, GiB, TiB }

export function getUnit(b: number): BinaryUnit {
if (b < MiB) return 'KiB'
if (b < GiB) return 'MiB'
if (b < TiB) return 'GiB'
return 'TiB'
}

/** Takes a raw byte count and determines the appropriate unit to use in formatting it */
export const formatBytes = (b: number, digits = 2): FormattedBytes => {
const unit = getUnit(b)
return { number: formatBytesAs(b, unit, digits), unit }
}

// Used when we have multiple related numbers that might normally round to different units.
// Once the proper "unified" unit base is established, all numbers can be converted to a specific unit.
export const formatBytesAs = (bytes: number, unit: BinaryUnit, digits = 2): number =>
round(bytes / unitSize[unit], digits)
16 changes: 8 additions & 8 deletions mock-api/silo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ export const siloQuotas: Json<SiloQuotas[]> = [
{
silo_id: silos[0].id,
cpus: 50,
memory: 300 * GiB,
storage: 7 * TiB,
memory: 306.55 * GiB,
storage: 7.91 * TiB,
},
{
silo_id: silos[1].id,
cpus: 34,
memory: 500 * GiB,
storage: 9 * TiB,
memory: 500.02 * GiB,
storage: 9.36 * TiB,
},
]

Expand All @@ -62,14 +62,14 @@ export const siloProvisioned: Json<SiloQuotas[]> = [
{
silo_id: silos[0].id,
cpus: 30,
memory: 234 * GiB,
storage: 4.3 * TiB,
memory: 234.31 * GiB,
storage: 4.339 * TiB,
},
{
silo_id: silos[1].id,
cpus: 8,
memory: 150 * GiB,
storage: 2 * TiB,
memory: 150.529 * GiB,
storage: 2.75 * TiB,
},
]

Expand Down
10 changes: 5 additions & 5 deletions test/e2e/silos.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,13 @@ test('Quotas tab', async ({ page }) => {
})
await expectRowVisible(table, {
Resource: 'Memory',
Provisioned: '234 GiB',
Quota: '300 GiB',
Provisioned: '234.31 GiB',
Quota: '306.55 GiB',
})
await expectRowVisible(table, {
Resource: 'Storage',
Provisioned: '4403.2 GiB',
Quota: '7168 GiB',
Provisioned: '4.34 TiB',
Quota: '7.91 TiB',
})

const sideModal = page.getByRole('dialog', { name: 'Edit quotas' })
Expand Down Expand Up @@ -412,5 +412,5 @@ test('Quotas tab', async ({ page }) => {
// only one changes, the others stay the same
await expectRowVisible(table, { Resource: 'CPU', Quota: '50 vCPUs' })
await expectRowVisible(table, { Resource: 'Memory', Quota: '50 GiB' })
await expectRowVisible(table, { Resource: 'Storage', Quota: '7168 GiB' })
await expectRowVisible(table, { Resource: 'Storage', Quota: '7.91 TiB' })
})
Loading
Loading