diff --git a/app/components/CapacityBar.tsx b/app/components/CapacityBar.tsx
index cf13d943c..5c62f9686 100644
--- a/app/components/CapacityBar.tsx
+++ b/app/components/CapacityBar.tsx
@@ -62,6 +62,16 @@ function TitleCell({ icon, title, unit }: TitleCellProps) {
}
function PctCell({ pct }: { pct: number }) {
+ // NaN happens when both top and bottom are 0
+ if (Number.isNaN(pct)) {
+ return (
+
+ )
+ }
+
const [wholeNumber, decimal] = splitDecimal(pct)
return (
diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx
index 76bd9c4ef..782fd18db 100644
--- a/app/forms/silo-create.tsx
+++ b/app/forms/silo-create.tsx
@@ -20,6 +20,7 @@ import { SideModalForm } from '~/components/form/SideModalForm'
import { useForm, useToast } from '~/hooks'
import { FormDivider } from '~/ui/lib/Divider'
import { pb } from '~/util/path-builder'
+import { GiB } from '~/util/units'
export type SiloCreateFormValues = Omit
& {
siloAdminGetsFleetAdmin: boolean
@@ -74,6 +75,7 @@ export function CreateSiloSideModalForm() {
adminGroupName,
siloAdminGetsFleetAdmin,
siloViewerGetsFleetViewer,
+ quotas,
...rest
}) => {
const mappedFleetRoles: SiloCreate['mappedFleetRoles'] = {}
@@ -88,6 +90,11 @@ export function CreateSiloSideModalForm() {
// no point setting it to empty string or whitespace
adminGroupName: adminGroupName?.trim() || undefined,
mappedFleetRoles,
+ quotas: {
+ cpus: quotas.cpus,
+ memory: quotas.memory * GiB,
+ storage: quotas.storage * GiB,
+ },
...rest,
},
})
diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts
index 60a62a9d7..5fe57212d 100644
--- a/mock-api/msw/db.ts
+++ b/mock-api/msw/db.ts
@@ -236,10 +236,14 @@ export const lookup = {
export function utilizationForSilo(silo: Json) {
const quotas = db.siloQuotas.find((q) => q.silo_id === silo.id)
- if (!quotas) throw internalError()
+ if (!quotas) {
+ throw internalError(`no entry in db.siloQuotas for silo ${silo.name}`)
+ }
const provisioned = db.siloProvisioned.find((p) => p.silo_id === silo.id)
- if (!provisioned) throw internalError()
+ if (!provisioned) {
+ throw internalError(`no entry in db.siloProvisioned for silo ${silo.name}`)
+ }
return {
allocated: pick(quotas, 'cpus', 'storage', 'memory'),
diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts
index 9cc079591..e9f4f0252 100644
--- a/mock-api/msw/handlers.ts
+++ b/mock-api/msw/handlers.ts
@@ -1130,7 +1130,7 @@ export const handlers = makeHandlers({
requireFleetViewer(cookies)
return paginated(query, db.silos)
},
- siloCreate({ body, cookies }) {
+ siloCreate({ body: { quotas, ...body }, cookies }) {
requireFleetViewer(cookies)
errIfExists(db.silos, { name: body.name })
const newSilo: Json = {
@@ -1140,6 +1140,8 @@ export const handlers = makeHandlers({
mapped_fleet_roles: body.mapped_fleet_roles || {},
}
db.silos.push(newSilo)
+ db.siloQuotas.push({ silo_id: newSilo.id, ...quotas })
+ db.siloProvisioned.push({ silo_id: newSilo.id, cpus: 0, memory: 0, storage: 0 })
return json(newSilo, { status: 201 })
},
siloView({ path, cookies }) {
diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts
index 234a72e36..3727c1c3a 100644
--- a/mock-api/msw/util.ts
+++ b/mock-api/msw/util.ts
@@ -101,7 +101,8 @@ export const NotImplemented = () => {
throw json({ error_code: 'NotImplemented' }, { status: 501 })
}
-export const internalError = () => json({ error_code: 'InternalError' }, { status: 500 })
+export const internalError = (message: string) =>
+ json({ error_code: 'InternalError', message }, { status: 500 })
export const errIfExists = >(
collection: T[],
diff --git a/test/e2e/networking.e2e.ts b/test/e2e/networking.e2e.ts
index e2f663730..d22ad1d93 100644
--- a/test/e2e/networking.e2e.ts
+++ b/test/e2e/networking.e2e.ts
@@ -7,7 +7,7 @@
*/
import { expect, test } from '@playwright/test'
-import { expectNotVisible, expectVisible } from './utils'
+import { closeToast, expectNotVisible, expectVisible } from './utils'
test('Create and edit VPC', async ({ page }) => {
await page.goto('/projects/mock-project')
@@ -46,7 +46,7 @@ test('Create and edit VPC', async ({ page }) => {
await page.click('role=button[name="Update VPC"]')
// Close toast, it holds up the test for some reason
- await page.click('role=button[name="Dismiss notification"]')
+ await closeToast(page)
await expect(page.getByRole('link', { name: 'new-vpc' })).toBeVisible()
})
diff --git a/test/e2e/silos.e2e.ts b/test/e2e/silos.e2e.ts
index 9ef77691e..af733c3af 100644
--- a/test/e2e/silos.e2e.ts
+++ b/test/e2e/silos.e2e.ts
@@ -32,16 +32,17 @@ test('Create silo', async ({ page }) => {
await page.click('role=link[name="New silo"]')
// fill out form
- await page.fill('role=textbox[name="Name"]', 'other-silo')
- await page.fill('role=textbox[name="Description"]', 'definitely a silo')
- await expect(page.locator('role=checkbox[name="Discoverable"]')).toBeChecked()
- await page.click('role=checkbox[name="Discoverable"]')
- await page.click('role=radio[name="Local only"]')
- await page.fill('role=textbox[name="Admin group name"]', 'admins')
- await page.click('role=checkbox[name="Grant fleet admin role to silo admins"]')
- await page.getByRole('textbox', { name: 'CPU quota (nCPUs)' }).fill('3')
- await page.getByRole('textbox', { name: 'Memory quota (GiB)' }).fill('5')
- await page.getByRole('textbox', { name: 'Storage quota (GiB)' }).fill('7')
+ await page.getByRole('textbox', { name: 'Name', exact: true }).fill('other-silo')
+ await page.getByRole('textbox', { name: 'Description' }).fill('definitely a silo')
+ const discoverable = page.getByRole('checkbox', { name: 'Discoverable' })
+ await expect(discoverable).toBeChecked()
+ await discoverable.click()
+ await page.getByRole('radio', { name: 'Local only' }).click()
+ await page.getByRole('textbox', { name: 'Admin group name' }).fill('admins')
+ await page.getByRole('checkbox', { name: 'Grant fleet admin' }).click()
+ await page.getByRole('textbox', { name: 'CPU quota' }).fill('30')
+ await page.getByRole('textbox', { name: 'Memory quota' }).fill('58')
+ await page.getByRole('textbox', { name: 'Storage quota' }).fill('735')
// Add a TLS cert
const openCertModalButton = page.getByRole('button', { name: 'Add TLS certificate' })
@@ -114,6 +115,17 @@ test('Create silo', async ({ page }) => {
await page.goBack()
+ // now go check the quotas in its entry in the utilization table
+ await page.getByRole('link', { name: 'Utilization' }).click()
+ await expectRowVisible(page.getByRole('table'), {
+ Silo: 'other-silo',
+ CPU: '30',
+ Memory: '58 GiB',
+ Storage: '0.72 TiB',
+ })
+
+ await page.goBack()
+
// now delete it
await page.locator('role=button[name="Row actions"]').nth(2).click()
await page.click('role=menuitem[name="Delete"]')
diff --git a/test/e2e/utilization.e2e.ts b/test/e2e/utilization.e2e.ts
index 72eaff798..060098ec3 100644
--- a/test/e2e/utilization.e2e.ts
+++ b/test/e2e/utilization.e2e.ts
@@ -5,7 +5,14 @@
*
* Copyright Oxide Computer Company
*/
-import { expect, expectRowVisible, getPageAsUser, test } from './utils'
+import {
+ clickRowAction,
+ closeToast,
+ expect,
+ expectRowVisible,
+ getPageAsUser,
+ test,
+} from './utils'
// not trying to get elaborate here. just make sure the pages load, which
// exercises the loader prefetches and invariant checks
@@ -41,6 +48,43 @@ test.describe('System utilization', () => {
await page.goto('/system/utilization')
await expect(page.getByText('Page not found')).toBeVisible()
})
+
+ test('zero over zero', async ({ page }) => {
+ // easiest way to test this is to create a silo with zero quotas and delete
+ // the other two silos so it's the only one shown on system utilization.
+ // Otherwise we'd have to create a user in the silo to see the utilization
+ // inside the silo
+
+ await page.goto('/system/silos-new')
+ await page.getByRole('textbox', { name: 'Name', exact: true }).fill('all-zeros')
+ // don't need to set silo values, they're zero by default
+ await page.getByRole('button', { name: 'Create silo' }).click()
+
+ await closeToast(page)
+
+ const confirm = page.getByRole('button', { name: 'Confirm' })
+
+ await clickRowAction(page, 'maze-war', 'Delete')
+ await confirm.click()
+ await expect(page.getByRole('cell', { name: 'maze-war' })).toBeHidden()
+
+ await clickRowAction(page, 'myriad', 'Delete')
+ await confirm.click()
+ await expect(page.getByRole('cell', { name: 'myriad' })).toBeHidden()
+
+ await page.getByRole('link', { name: 'Utilization' }).click()
+
+ // all three capacity bars are zeroed out
+ await expect(page.getByText('—%')).toHaveCount(3)
+ await expect(page.getByText('NaN')).toBeHidden()
+
+ await expectRowVisible(page.getByRole('table'), {
+ Silo: 'all-zeros',
+ CPU: '0',
+ Memory: '0 GiB',
+ Storage: '0 TiB',
+ })
+ })
})
test.describe('Silo utilization', () => {
diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts
index 198090cb6..8143ee99b 100644
--- a/test/e2e/utils.ts
+++ b/test/e2e/utils.ts
@@ -106,10 +106,16 @@ export async function expectRowVisible(
export async function stopInstance(page: Page) {
await page.click('role=button[name="Instance actions"]')
await page.click('role=menuitem[name="Stop"]')
- // close toast and wait for it to fade out. for some reason it prevents things
- // from working, but only in tests as far as we can tell
- await page.click('role=button[name="Dismiss notification"]')
- await sleep(2000)
+ await closeToast(page)
+}
+
+/**
+ * Close toast and wait for it to fade out. For some reason it prevents things
+ * from working, but only in tests as far as we can tell.
+ */
+export async function closeToast(page: Page) {
+ await page.getByRole('button', { name: 'Dismiss notification' }).click()
+ await sleep(1000)
}
/**