/
@@ -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,