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) } /**