diff --git a/app/components/CapacityBars.tsx b/app/components/CapacityBars.tsx index f5d415cf1..6ecb47846 100644 --- a/app/components/CapacityBars.tsx +++ b/app/components/CapacityBars.tsx @@ -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' @@ -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 (
} title="MEMORY" - unit="GiB" - provisioned={bytesToGiB(provisioned.memory)} - capacity={bytesToGiB(allocated.memory)} + unit={memoryUnit} + provisioned={provisionedMemory} + capacity={allocatedMemory} capacityLabel={allocatedLabel} /> } title="STORAGE" - unit="TiB" - provisioned={bytesToTiB(provisioned.storage)} - capacity={bytesToTiB(allocated.storage)} + unit={storageUnit} + provisioned={provisionedStorage} + capacity={allocatedStorage} capacityLabel={allocatedLabel} />
diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index 9c690b263..be373924b 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -14,6 +14,7 @@ import { FLEET_ID, totalUtilization, usePrefetchedApiQuery, + type SiloUtilization, } from '@oxide/api' import { Metrics16Icon, Metrics24Icon } from '@oxide/design-system/icons/react' @@ -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([ @@ -196,38 +203,23 @@ function UsageTab() { /> - + - + - + - + @@ -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) => (
{provisioned} / @@ -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 (
@@ -283,3 +265,35 @@ const AvailableCell = ({
) } + +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 + + // Use the original values to calculate the usage percentage + const usagePercent = (provisionedRaw / allocatedRaw) * 100 + return ( + + ) +} diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 8b913e1c0..88ec066c9 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -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` @@ -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) @@ -61,19 +68,19 @@ export function SiloQuotasTab() { Memory - {bytesToGiB(provisioned.memory)} GiB + {provisionedMemory} {memoryUnits} - {bytesToGiB(quotas.memory)} GiB + {quotasMemory} {memoryUnits} Storage - {bytesToGiB(provisioned.storage)} GiB + {provisionedStorage} {storageUnits} - {bytesToGiB(quotas.storage)} GiB + {quotasStorage} {storageUnits} diff --git a/app/util/units.spec.ts b/app/util/units.spec.ts new file mode 100644 index 000000000..d324c982d --- /dev/null +++ b/app/util/units.spec.ts @@ -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) diff --git a/app/util/units.ts b/app/util/units.ts index e88a5672a..2f0489018 100644 --- a/app/util/units.ts +++ b/app/util/units.ts @@ -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 = { 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) diff --git a/mock-api/silo.ts b/mock-api/silo.ts index c764e0852..91a8fd887 100644 --- a/mock-api/silo.ts +++ b/mock-api/silo.ts @@ -44,14 +44,14 @@ export const siloQuotas: Json = [ { 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, }, ] @@ -62,14 +62,14 @@ export const siloProvisioned: Json = [ { 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, }, ] diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts index e0b8dd1dc..f3078650f 100644 --- a/test/e2e/silos.e2e.ts +++ b/test/e2e/silos.e2e.ts @@ -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' }) @@ -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' }) }) diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts index 382c8eaea..1ee10555a 100644 --- a/test/e2e/utilization.e2e.ts +++ b/test/e2e/utilization.e2e.ts @@ -24,21 +24,21 @@ test.describe('System utilization', () => { await page.goto('/system/utilization') await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page.getByText('Provisioned384 GiB')).toBeVisible() + await expect(page.getByText('Provisioned384.84 GiB')).toBeVisible() await expect(page.getByText('Provisioned / Quota')).toBeVisible() const table = page.getByRole('table') await expectRowVisible(table, { CPU: '20', - Storage: '2.7 TiB', - Memory: '66 GiB', + Storage: '3.57 TiB', + Memory: '72.24 GiB', Silo: 'maze-war', }) await expectRowVisible(table, { CPU: '26', - Storage: '7 TiB', - Memory: '350 GiB', + Storage: '6.61 TiB', + Memory: '349.49 GiB', Silo: 'myriad', }) @@ -98,8 +98,8 @@ test.describe('System utilization', () => { await expectRowVisible(page.getByRole('table'), { Silo: 'all-zeros', CPU: '0', - Memory: '0 GiB', - Storage: '0 TiB', + Memory: '0 KiB', + Storage: '0 KiB', }) }) }) @@ -109,7 +109,7 @@ test.describe('Silo utilization', () => { await page.goto('/utilization') await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() // Capacity bars are showing up - await expect(page.getByText('Provisioned234 GiB')).toBeVisible() + await expect(page.getByText('Provisioned234.31 GiB')).toBeVisible() }) test('works for dev user', async ({ browser }) => { @@ -117,10 +117,44 @@ test.describe('Silo utilization', () => { await page.goto('/utilization') await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() // Capacity bars are showing up - await expect(page.getByText('Provisioned234 GiB')).toBeVisible() + await expect(page.getByText('Provisioned234.31 GiB')).toBeVisible() }) }) +test('Utilization shows correct units for CPU, memory, and storage', async ({ page }) => { + await page.goto('/system/utilization') + + // Verify the original values for the Memory tile + await expect(page.getByText('Memory(GIB)')).toBeVisible() + await expect(page.getByText('Provisioned384.84 GiB')).toBeVisible() + await expect(page.getByText('Quota (Total)806.57 GiB')).toBeVisible() + + // Navigate to the quotas tab + await page.goto('system/silos/maze-war?tab=quotas') + + // Verify that there's a row for memory with the correct units + const table = page.getByRole('table') + await expectRowVisible(table, { Provisioned: '234.31 GiB', Quota: '306.55 GiB' }) + await expect(page.getByText('2.93 TiB')).toBeHidden() + + // Edit the quota and verify the new value + await page.getByRole('button', { name: 'Edit quotas' }).click() + await page.getByRole('textbox', { name: 'Memory (GiB)' }).fill('3000') + await page.getByRole('button', { name: 'Update quotas' }).click() + // Verify that the table has been updated + await expectRowVisible(table, { Provisioned: '0.23 TiB', Quota: '2.93 TiB' }) + await expect(page.getByText('306.55 GiB')).toBeHidden() + + // Navigate back to the utilization page without refreshing + await page.getByRole('link', { name: 'Utilization' }).click() + await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() + + // Verify the updated values for the Memory tile + await expect(page.getByText('Memory(TiB)')).toBeVisible() + await expect(page.getByText('Provisioned0.38 TiB')).toBeVisible() + await expect(page.getByText('Quota (Total)3.42 TiB')).toBeVisible() +}) + // TODO: it would be nice to test that actual data shows up in the graphs and // the date range picker works as expected, but it's hard to do asserts about // the graphs because they're big SVGs, the data coming from MSW is randomized,